open_sesame/platform/
cosmic_theme.rs

1//! COSMIC Desktop Theme Integration
2//!
3//! Reads theme colors, fonts, and mode directly from COSMIC's configuration.
4//! Provides native integration with the COSMIC desktop environment.
5//!
6//! Configuration paths:
7//! - Theme mode: ~/.config/cosmic/com.system76.CosmicTheme.Mode/v1/is_dark
8//! - Dark theme: ~/.config/cosmic/com.system76.CosmicTheme.Dark/v1/
9//! - Light theme: ~/.config/cosmic/com.system76.CosmicTheme.Light/v1/
10//! - Fonts: ~/.config/cosmic/com.system76.CosmicTk/v1/
11
12use serde::Deserialize;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16/// RGBA color from COSMIC theme (0.0-1.0 floats)
17///
18/// Matches COSMIC's color representation in RON configuration files.
19#[derive(Debug, Clone, Copy, Deserialize)]
20pub struct CosmicColor {
21    /// Red channel (0.0 to 1.0)
22    pub red: f32,
23    /// Green channel (0.0 to 1.0)
24    pub green: f32,
25    /// Blue channel (0.0 to 1.0)
26    pub blue: f32,
27    /// Alpha channel (0.0 to 1.0, defaults to 1.0)
28    #[serde(default = "default_alpha")]
29    pub alpha: f32,
30}
31
32fn default_alpha() -> f32 {
33    1.0
34}
35
36impl CosmicColor {
37    /// Convert to u8 RGBA tuple (0-255 per channel)
38    pub fn to_rgba(&self) -> (u8, u8, u8, u8) {
39        (
40            (self.red.clamp(0.0, 1.0) * 255.0) as u8,
41            (self.green.clamp(0.0, 1.0) * 255.0) as u8,
42            (self.blue.clamp(0.0, 1.0) * 255.0) as u8,
43            (self.alpha.clamp(0.0, 1.0) * 255.0) as u8,
44        )
45    }
46}
47
48/// Component colors from COSMIC theme
49///
50/// Represents the various states a UI component can have (base, hover, pressed, etc.)
51#[derive(Debug, Clone, Deserialize)]
52pub struct ComponentColors {
53    /// Default/resting state color
54    pub base: CosmicColor,
55    /// Color when hovered
56    pub hover: CosmicColor,
57    /// Color when pressed/active
58    pub pressed: CosmicColor,
59    /// Color when selected
60    pub selected: CosmicColor,
61    /// Text color when selected
62    pub selected_text: CosmicColor,
63    /// Color when focused
64    pub focus: CosmicColor,
65    /// Foreground/text color on this component
66    pub on: CosmicColor,
67}
68
69/// Container structure from COSMIC theme (background, primary, secondary)
70///
71/// Containers are layered surfaces in COSMIC's design system.
72#[derive(Debug, Clone, Deserialize)]
73pub struct Container {
74    /// Base background color for this container layer
75    pub base: CosmicColor,
76    /// Colors for interactive components within this container
77    pub component: ComponentColors,
78    /// Foreground/text color on this container
79    pub on: CosmicColor,
80}
81
82/// Accent colors from COSMIC theme
83///
84/// The accent color is the primary brand/highlight color.
85#[derive(Debug, Clone, Deserialize)]
86pub struct AccentColors {
87    /// Default accent color
88    pub base: CosmicColor,
89    /// Accent color when hovered
90    pub hover: CosmicColor,
91    /// Accent color when focused
92    pub focus: CosmicColor,
93    /// Foreground color on accent backgrounds
94    pub on: CosmicColor,
95}
96
97/// Corner radii from COSMIC theme
98///
99/// COSMIC uses a consistent set of corner radii across the desktop.
100/// Each radius is an array of 4 floats for [top-left, top-right, bottom-right, bottom-left].
101#[derive(Debug, Clone, Deserialize)]
102pub struct CornerRadii {
103    /// No rounding (0px)
104    pub radius_0: [f32; 4],
105    /// Extra small radius (~4px)
106    pub radius_xs: [f32; 4],
107    /// Small radius (~8px)
108    pub radius_s: [f32; 4],
109    /// Medium radius (~16px) - typical for cards and popups
110    pub radius_m: [f32; 4],
111    /// Large radius (~24px)
112    pub radius_l: [f32; 4],
113    /// Extra large radius (~32px)
114    pub radius_xl: [f32; 4],
115}
116
117impl Default for CornerRadii {
118    fn default() -> Self {
119        Self {
120            radius_0: [0.0; 4],
121            radius_xs: [4.0; 4],
122            radius_s: [8.0; 4],
123            radius_m: [16.0; 4],
124            radius_l: [24.0; 4],
125            radius_xl: [32.0; 4],
126        }
127    }
128}
129
130/// Spacing values from COSMIC theme
131///
132/// COSMIC uses a consistent spacing scale across the desktop.
133#[derive(Debug, Clone, Deserialize)]
134pub struct Spacing {
135    /// No spacing (0px)
136    pub space_none: u16,
137    /// Triple extra small (~4px)
138    pub space_xxxs: u16,
139    /// Double extra small (~8px)
140    pub space_xxs: u16,
141    /// Extra small (~12px)
142    pub space_xs: u16,
143    /// Small (~16px)
144    pub space_s: u16,
145    /// Medium (~24px)
146    pub space_m: u16,
147    /// Large (~32px)
148    pub space_l: u16,
149    /// Extra large (~48px)
150    pub space_xl: u16,
151    /// Double extra large (~64px)
152    pub space_xxl: u16,
153    /// Triple extra large (~128px)
154    pub space_xxxl: u16,
155}
156
157impl Default for Spacing {
158    fn default() -> Self {
159        Self {
160            space_none: 0,
161            space_xxxs: 4,
162            space_xxs: 8,
163            space_xs: 12,
164            space_s: 16,
165            space_m: 24,
166            space_l: 32,
167            space_xl: 48,
168            space_xxl: 64,
169            space_xxxl: 128,
170        }
171    }
172}
173
174/// Complete COSMIC theme for open-sesame
175///
176/// Aggregates all theme components needed for rendering the overlay.
177#[derive(Debug, Clone)]
178pub struct CosmicTheme {
179    /// Whether dark mode is active
180    pub is_dark: bool,
181    /// Background container colors (desktop/root level)
182    pub background: Container,
183    /// Primary container colors (cards, popups, dialogs)
184    pub primary: Container,
185    /// Secondary container colors (nested containers)
186    pub secondary: Container,
187    /// Accent colors for highlights and selection
188    pub accent: AccentColors,
189    /// Corner radii for rounded elements
190    pub corner_radii: CornerRadii,
191    /// Spacing scale for layout
192    pub spacing: Spacing,
193}
194
195impl CosmicTheme {
196    /// Load COSMIC theme from system configuration
197    ///
198    /// Reads from ~/.config/cosmic/ and returns None if COSMIC theme
199    /// files are not present (e.g., not running on COSMIC desktop).
200    pub fn load() -> Option<Self> {
201        let is_dark = read_is_dark().unwrap_or(true);
202        let theme_dir = if is_dark {
203            cosmic_theme_dark_dir()
204        } else {
205            cosmic_theme_light_dir()
206        };
207
208        tracing::debug!(
209            "Loading COSMIC theme from: {:?} (dark={})",
210            theme_dir,
211            is_dark
212        );
213
214        let background = read_container(&theme_dir, "background")?;
215        let primary = read_container(&theme_dir, "primary")?;
216        let secondary = read_container(&theme_dir, "secondary")?;
217        let accent = read_accent(&theme_dir)?;
218        let corner_radii = read_corner_radii(&theme_dir).unwrap_or_default();
219        let spacing = read_spacing(&theme_dir).unwrap_or_default();
220
221        tracing::info!(
222            "Loaded COSMIC {} theme",
223            if is_dark { "dark" } else { "light" }
224        );
225
226        Some(Self {
227            is_dark,
228            background,
229            primary,
230            secondary,
231            accent,
232            corner_radii,
233            spacing,
234        })
235    }
236}
237
238/// Get COSMIC config directory base
239fn cosmic_config_dir() -> Option<PathBuf> {
240    dirs::config_dir().map(|d| d.join("cosmic"))
241}
242
243/// Get COSMIC theme mode directory
244fn cosmic_theme_mode_dir() -> Option<PathBuf> {
245    cosmic_config_dir().map(|d| d.join("com.system76.CosmicTheme.Mode/v1"))
246}
247
248/// Get COSMIC dark theme directory
249fn cosmic_theme_dark_dir() -> PathBuf {
250    cosmic_config_dir()
251        .map(|d| d.join("com.system76.CosmicTheme.Dark/v1"))
252        .unwrap_or_else(|| PathBuf::from("/nonexistent"))
253}
254
255/// Get COSMIC light theme directory
256fn cosmic_theme_light_dir() -> PathBuf {
257    cosmic_config_dir()
258        .map(|d| d.join("com.system76.CosmicTheme.Light/v1"))
259        .unwrap_or_else(|| PathBuf::from("/nonexistent"))
260}
261
262/// Read whether dark mode is enabled
263fn read_is_dark() -> Option<bool> {
264    let path = cosmic_theme_mode_dir()?.join("is_dark");
265    let content = fs::read_to_string(&path).ok()?;
266    ron::from_str(&content).ok()
267}
268
269/// Read a container (background, primary, secondary) from theme dir
270fn read_container(theme_dir: &Path, name: &str) -> Option<Container> {
271    let path = theme_dir.join(name);
272    let content = fs::read_to_string(&path).ok()?;
273    match ron::from_str(&content) {
274        Ok(c) => Some(c),
275        Err(e) => {
276            tracing::warn!("Failed to parse COSMIC {} config: {}", name, e);
277            None
278        }
279    }
280}
281
282/// Read accent colors from theme dir
283fn read_accent(theme_dir: &Path) -> Option<AccentColors> {
284    let path = theme_dir.join("accent");
285    let content = fs::read_to_string(&path).ok()?;
286    match ron::from_str(&content) {
287        Ok(a) => Some(a),
288        Err(e) => {
289            tracing::warn!("Failed to parse COSMIC accent config: {}", e);
290            None
291        }
292    }
293}
294
295/// Read corner radii from theme dir
296fn read_corner_radii(theme_dir: &Path) -> Option<CornerRadii> {
297    let path = theme_dir.join("corner_radii");
298    let content = fs::read_to_string(&path).ok()?;
299    ron::from_str(&content).ok()
300}
301
302/// Read spacing from theme dir
303fn read_spacing(theme_dir: &Path) -> Option<Spacing> {
304    let path = theme_dir.join("spacing");
305    let content = fs::read_to_string(&path).ok()?;
306    ron::from_str(&content).ok()
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_cosmic_color_conversion() {
315        let color = CosmicColor {
316            red: 1.0,
317            green: 0.5,
318            blue: 0.0,
319            alpha: 0.8,
320        };
321        let (r, g, b, a) = color.to_rgba();
322        assert_eq!(r, 255);
323        assert_eq!(g, 127);
324        assert_eq!(b, 0);
325        assert_eq!(a, 204);
326    }
327
328    #[test]
329    fn test_load_cosmic_theme() {
330        // This will fail if not running on COSMIC, which is fine
331        let theme = CosmicTheme::load();
332        if let Some(t) = theme {
333            println!("Loaded COSMIC theme: dark={}", t.is_dark);
334        }
335    }
336}