1use serde::Deserialize;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Copy, Deserialize)]
20pub struct CosmicColor {
21 pub red: f32,
23 pub green: f32,
25 pub blue: f32,
27 #[serde(default = "default_alpha")]
29 pub alpha: f32,
30}
31
32fn default_alpha() -> f32 {
33 1.0
34}
35
36impl CosmicColor {
37 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#[derive(Debug, Clone, Deserialize)]
52pub struct ComponentColors {
53 pub base: CosmicColor,
55 pub hover: CosmicColor,
57 pub pressed: CosmicColor,
59 pub selected: CosmicColor,
61 pub selected_text: CosmicColor,
63 pub focus: CosmicColor,
65 pub on: CosmicColor,
67}
68
69#[derive(Debug, Clone, Deserialize)]
73pub struct Container {
74 pub base: CosmicColor,
76 pub component: ComponentColors,
78 pub on: CosmicColor,
80}
81
82#[derive(Debug, Clone, Deserialize)]
86pub struct AccentColors {
87 pub base: CosmicColor,
89 pub hover: CosmicColor,
91 pub focus: CosmicColor,
93 pub on: CosmicColor,
95}
96
97#[derive(Debug, Clone, Deserialize)]
102pub struct CornerRadii {
103 pub radius_0: [f32; 4],
105 pub radius_xs: [f32; 4],
107 pub radius_s: [f32; 4],
109 pub radius_m: [f32; 4],
111 pub radius_l: [f32; 4],
113 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#[derive(Debug, Clone, Deserialize)]
134pub struct Spacing {
135 pub space_none: u16,
137 pub space_xxxs: u16,
139 pub space_xxs: u16,
141 pub space_xs: u16,
143 pub space_s: u16,
145 pub space_m: u16,
147 pub space_l: u16,
149 pub space_xl: u16,
151 pub space_xxl: u16,
153 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#[derive(Debug, Clone)]
178pub struct CosmicTheme {
179 pub is_dark: bool,
181 pub background: Container,
183 pub primary: Container,
185 pub secondary: Container,
187 pub accent: AccentColors,
189 pub corner_radii: CornerRadii,
191 pub spacing: Spacing,
193}
194
195impl CosmicTheme {
196 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
238fn cosmic_config_dir() -> Option<PathBuf> {
240 dirs::config_dir().map(|d| d.join("cosmic"))
241}
242
243fn cosmic_theme_mode_dir() -> Option<PathBuf> {
245 cosmic_config_dir().map(|d| d.join("com.system76.CosmicTheme.Mode/v1"))
246}
247
248fn 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
255fn 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
262fn 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
269fn 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
282fn 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
295fn 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
302fn 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 let theme = CosmicTheme::load();
332 if let Some(t) = theme {
333 println!("Loaded COSMIC theme: dark={}", t.is_dark);
334 }
335 }
336}