Skip to main content

retch_sysinfo/
theme.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! UI theme, icon, cursor, and font detection.
5
6#[allow(dead_code)]
7fn parse_ini_key(content: &str, key: &str) -> Option<String> {
8    for line in content.lines() {
9        let line = line.trim();
10        if line.starts_with('#') || line.starts_with(';') {
11            continue;
12        }
13        if let Some(pos) = line.find('=') {
14            let k = line[..pos].trim();
15            if k == key {
16                let v = line[pos + 1..].trim();
17                let v = if (v.starts_with('"') && v.ends_with('"'))
18                    || (v.starts_with('\'') && v.ends_with('\''))
19                {
20                    if v.len() >= 2 {
21                        v[1..v.len() - 1].to_string()
22                    } else {
23                        v.to_string()
24                    }
25                } else {
26                    v.to_string()
27                };
28                if !v.is_empty() {
29                    return Some(v);
30                }
31            }
32        }
33    }
34    None
35}
36
37#[cfg(target_os = "linux")]
38fn get_gtk_setting(key: &str) -> Option<String> {
39    let home = dirs::home_dir()?;
40    let paths = [
41        home.join(".config/gtk-4.0/settings.ini"),
42        home.join(".config/gtk-3.0/settings.ini"),
43        home.join(".config/gtk-2.0/settings.ini"),
44        home.join(".gtkrc-2.0"),
45    ];
46    for path in &paths {
47        if path.exists() {
48            if let Ok(contents) = std::fs::read_to_string(path) {
49                if let Some(val) = parse_ini_key(&contents, key) {
50                    return Some(val);
51                }
52            }
53        }
54    }
55    None
56}
57
58#[cfg(target_os = "linux")]
59fn query_gsettings(schema: &str, key: &str) -> Option<String> {
60    let output = std::process::Command::new("gsettings")
61        .args(["get", schema, key])
62        .output()
63        .ok()?;
64    if output.status.success() {
65        let val = String::from_utf8_lossy(&output.stdout).trim().to_string();
66        let val = val.trim_matches('\'').trim_matches('"').to_string();
67        if !val.is_empty() && val != "''" && val != "\"\"" {
68            return Some(val);
69        }
70    }
71    None
72}
73
74#[cfg(target_os = "linux")]
75fn get_kde_setting(key: &str) -> Option<String> {
76    let home = dirs::home_dir()?;
77    let path = home.join(".config/kdeglobals");
78    if path.exists() {
79        if let Ok(contents) = std::fs::read_to_string(path) {
80            return parse_ini_key(&contents, key);
81        }
82    }
83    None
84}
85
86pub(crate) fn get_default_monospace_font() -> Option<String> {
87    #[cfg(target_os = "linux")]
88    {
89        if let Ok(output) = std::process::Command::new("fc-match")
90            .arg("monospace")
91            .output()
92        {
93            if output.status.success() {
94                let s = String::from_utf8_lossy(&output.stdout);
95                if let Some(start) = s.find('"') {
96                    if let Some(end) = s[start + 1..].find('"') {
97                        return Some(s[start + 1..start + 1 + end].to_string());
98                    }
99                }
100            }
101        }
102        return None;
103    }
104
105    #[cfg(target_os = "macos")]
106    {
107        return Some("SF Mono".to_string());
108    }
109
110    #[cfg(target_os = "windows")]
111    {
112        return Some("Consolas".to_string());
113    }
114
115    #[allow(unreachable_code)]
116    None
117}
118
119#[cfg(target_os = "linux")]
120pub(crate) fn detect_ui_theme_and_fonts() -> (
121    Option<String>,
122    Option<String>,
123    Option<String>,
124    Option<String>,
125) {
126    let gtk_theme = get_gtk_setting("gtk-theme-name")
127        .or_else(|| query_gsettings("org.gnome.desktop.interface", "gtk-theme"));
128    let gtk_icons = get_gtk_setting("gtk-icon-theme-name")
129        .or_else(|| query_gsettings("org.gnome.desktop.interface", "icon-theme"));
130    let gtk_cursor = get_gtk_setting("gtk-cursor-theme-name")
131        .or_else(|| query_gsettings("org.gnome.desktop.interface", "cursor-theme"));
132    let gtk_font = get_gtk_setting("gtk-font-name")
133        .or_else(|| query_gsettings("org.gnome.desktop.interface", "font-name"));
134
135    let qt_theme = get_kde_setting("widgetStyle").or_else(|| get_kde_setting("ColorScheme"));
136    let qt_icons = get_kde_setting("iconTheme");
137    let qt_cursor = {
138        let home = dirs::home_dir();
139        home.and_then(|h| {
140            let path = h.join(".config/kcminputrc");
141            if path.exists() {
142                std::fs::read_to_string(path)
143                    .ok()
144                    .and_then(|contents| parse_ini_key(&contents, "theme"))
145            } else {
146                None
147            }
148        })
149    };
150    let qt_font = get_kde_setting("font").map(|f| {
151        let parts: Vec<&str> = f.split(',').collect();
152        if parts.len() >= 2 {
153            let name = parts[0].trim();
154            let size = parts[1].trim();
155            format!("{} ({}pt)", name, size)
156        } else {
157            f
158        }
159    });
160
161    let de = std::env::var("XDG_CURRENT_DESKTOP")
162        .or_else(|_| std::env::var("DESKTOP_SESSION"))
163        .unwrap_or_default()
164        .to_lowercase();
165    let is_kde = de.contains("kde") || de.contains("plasma");
166
167    let theme = if is_kde {
168        match (qt_theme, gtk_theme) {
169            (Some(qt), Some(gt)) => Some(format!("{} [Qt], {} [GTK]", qt, gt)),
170            (Some(qt), None) => Some(format!("{} [Qt]", qt)),
171            (None, Some(gt)) => Some(format!("{} [GTK]", gt)),
172            (None, None) => None,
173        }
174    } else {
175        match (gtk_theme, qt_theme) {
176            (Some(gt), Some(qt)) => Some(format!("{} [GTK], {} [Qt]", gt, qt)),
177            (Some(gt), None) => Some(format!("{} [GTK]", gt)),
178            (None, Some(qt)) => Some(format!("{} [Qt]", qt)),
179            (None, None) => None,
180        }
181    };
182
183    let icons = if is_kde {
184        match (qt_icons, gtk_icons) {
185            (Some(qi), Some(gi)) => Some(format!("{} [Qt], {} [GTK]", qi, gi)),
186            (Some(qi), None) => Some(format!("{} [Qt]", qi)),
187            (None, Some(gi)) => Some(format!("{} [GTK]", gi)),
188            (None, None) => None,
189        }
190    } else {
191        match (gtk_icons, qt_icons) {
192            (Some(gi), Some(qi)) => Some(format!("{} [GTK], {} [Qt]", gi, qi)),
193            (Some(gi), None) => Some(format!("{} [GTK]", gi)),
194            (None, Some(qi)) => Some(format!("{} [Qt]", qi)),
195            (None, None) => None,
196        }
197    };
198
199    let cursor = if is_kde {
200        match (qt_cursor, gtk_cursor) {
201            (Some(qc), Some(gc)) => Some(format!("{} [Qt], {} [GTK]", qc, gc)),
202            (Some(qc), None) => Some(format!("{} [Qt]", qc)),
203            (None, Some(gc)) => Some(format!("{} [GTK]", gc)),
204            (None, None) => None,
205        }
206    } else {
207        match (gtk_cursor, qt_cursor) {
208            (Some(gc), Some(qc)) => Some(format!("{} [GTK], {} [Qt]", gc, qc)),
209            (Some(gc), None) => Some(format!("{} [GTK]", gc)),
210            (None, Some(qc)) => Some(format!("{} [Qt]", qc)),
211            (None, None) => None,
212        }
213    };
214
215    let font = if is_kde {
216        match (qt_font, gtk_font) {
217            (Some(qf), Some(gf)) => Some(format!("{} [Qt], {} [GTK]", qf, gf)),
218            (Some(qf), None) => Some(format!("{} [Qt]", qf)),
219            (None, Some(gf)) => Some(format!("{} [GTK]", gf)),
220            (None, None) => None,
221        }
222    } else {
223        match (gtk_font, qt_font) {
224            (Some(gf), Some(qf)) => Some(format!("{} [GTK], {} [Qt]", gf, qf)),
225            (Some(gf), None) => Some(format!("{} [GTK]", gf)),
226            (None, Some(qf)) => Some(format!("{} [Qt]", qf)),
227            (None, None) => None,
228        }
229    };
230
231    (theme, icons, cursor, font)
232}
233
234#[cfg(target_os = "macos")]
235pub(crate) fn detect_ui_theme_and_fonts() -> (
236    Option<String>,
237    Option<String>,
238    Option<String>,
239    Option<String>,
240) {
241    let theme = match crate::macos_ffi::get_macos_appearance() {
242        Some(style) => Some(format!("Aqua ({})", style)),
243        None => Some("Aqua (Light)".to_string()),
244    };
245
246    (theme, None, None, Some("San Francisco".to_string()))
247}
248
249#[cfg(target_os = "windows")]
250pub(crate) fn detect_ui_theme_and_fonts() -> (
251    Option<String>,
252    Option<String>,
253    Option<String>,
254    Option<String>,
255) {
256    use crate::win_reg;
257    let theme = {
258        let apps_light = win_reg::get_reg_u32(
259            win_reg::HKEY_CURRENT_USER,
260            "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
261            "AppsUseLightTheme",
262        );
263
264        let apps_dark = apps_light.map(|val| val == 0);
265
266        match apps_dark {
267            Some(true) => Some("Dark".to_string()),
268            Some(false) => Some("Light".to_string()),
269            None => {
270                let output = std::process::Command::new("reg")
271                    .args([
272                        "query",
273                        r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
274                        "/v",
275                        "AppsUseLightTheme",
276                    ])
277                    .output()
278                    .ok();
279
280                let cmd_dark = output.and_then(|o| {
281                    if o.status.success() {
282                        let s = String::from_utf8_lossy(&o.stdout);
283                        if s.contains("0x0") {
284                            Some(true)
285                        } else if s.contains("0x1") {
286                            Some(false)
287                        } else {
288                            None
289                        }
290                    } else {
291                        None
292                    }
293                });
294
295                match cmd_dark {
296                    Some(true) => Some("Dark".to_string()),
297                    Some(false) => Some("Light".to_string()),
298                    None => Some("Unknown".to_string()),
299                }
300            }
301        }
302    };
303
304    (theme, None, None, Some("Segoe UI".to_string()))
305}
306
307#[allow(dead_code)]
308pub(crate) fn decode_percent_encoded(s: &str) -> String {
309    let mut bytes = Vec::with_capacity(s.len());
310    let raw = s.as_bytes();
311    let mut i = 0;
312    while i < raw.len() {
313        if raw[i] == b'%' && i + 2 < raw.len() {
314            if let (Ok(h1), Ok(h2)) = (
315                std::str::from_utf8(&raw[i + 1..i + 2]).map(|h| u8::from_str_radix(h, 16)),
316                std::str::from_utf8(&raw[i + 2..i + 3]).map(|h| u8::from_str_radix(h, 16)),
317            ) {
318                if let (Ok(h1), Ok(h2)) = (h1, h2) {
319                    bytes.push((h1 << 4) | h2);
320                    i += 3;
321                    continue;
322                }
323            }
324        }
325        bytes.push(raw[i]);
326        i += 1;
327    }
328    String::from_utf8_lossy(&bytes).to_string()
329}
330
331#[allow(dead_code)]
332pub(crate) fn clean_wallpaper_uri(uri: &str) -> Option<String> {
333    let trimmed = uri.trim().trim_matches('\'').trim_matches('"').trim();
334    if trimmed.is_empty() || trimmed == "''" || trimmed == "\"\"" {
335        return None;
336    }
337    let without_file = if let Some(rest) = trimmed.strip_prefix("file://") {
338        rest
339    } else {
340        trimmed
341    };
342    let decoded = decode_percent_encoded(without_file);
343    let cleaned = decoded.trim().to_string();
344    if cleaned.is_empty() {
345        None
346    } else {
347        Some(cleaned)
348    }
349}
350
351#[allow(dead_code)]
352pub(crate) fn parse_kwin_theme(content: &str) -> Option<String> {
353    let mut in_kdec = false;
354    let mut theme = None;
355    let mut library_name = None;
356
357    for line in content.lines() {
358        let line = line.trim();
359        if line.starts_with('[') && line.ends_with(']') {
360            let section = &line[1..line.len() - 1];
361            in_kdec = section == "org.kde.kdecoration2" || section.contains("kdecoration");
362            continue;
363        }
364        if in_kdec {
365            if let Some(pos) = line.find('=') {
366                let k = line[..pos].trim();
367                let v = line[pos + 1..].trim().trim_matches('"').trim_matches('\'');
368                if k == "theme" && !v.is_empty() {
369                    theme = Some(v.to_string());
370                } else if k == "library" || k == "libraryName" {
371                    library_name = Some(v.to_string());
372                }
373            }
374        }
375    }
376
377    let raw = theme.or(library_name)?;
378    let mut cleaned = raw.as_str();
379    for prefix in &[
380        "__aurorae__svg__",
381        "__aurorae__qml__",
382        "svg__",
383        "qml_",
384        "org.kde.",
385    ] {
386        if let Some(rest) = cleaned.strip_prefix(prefix) {
387            cleaned = rest;
388        }
389    }
390    if cleaned.is_empty() {
391        None
392    } else {
393        Some(cleaned.to_string())
394    }
395}
396
397#[allow(dead_code)]
398pub(crate) fn parse_xfwm4_theme(content: &str) -> Option<String> {
399    for line in content.lines() {
400        let line = line.trim();
401        if line.contains("name=\"theme\"") || line.contains("name=\"/general/theme\"") {
402            if let Some(start) = line.find("value=\"") {
403                let rest = &line[start + 7..];
404                if let Some(end) = rest.find('"') {
405                    let val = &rest[..end];
406                    if !val.is_empty() {
407                        return Some(val.to_string());
408                    }
409                }
410            }
411        }
412    }
413    None
414}
415
416#[allow(dead_code)]
417pub(crate) fn parse_openbox_theme(content: &str) -> Option<String> {
418    let mut in_theme = false;
419    for line in content.lines() {
420        let line = line.trim();
421        if line.starts_with("<theme>") {
422            in_theme = true;
423        }
424        if line.ends_with("</theme>") {
425            in_theme = false;
426        }
427        if in_theme || line.contains("<theme>") {
428            if let Some(start) = line.find("<name>") {
429                let rest = &line[start + 6..];
430                if let Some(end) = rest.find("</name>") {
431                    let val = rest[..end].trim();
432                    if !val.is_empty() {
433                        return Some(val.to_string());
434                    }
435                }
436            }
437        }
438    }
439    None
440}
441
442#[allow(dead_code)]
443pub(crate) fn parse_fluxbox_theme(content: &str) -> Option<String> {
444    for line in content.lines() {
445        let line = line.trim();
446        if let Some(rest) = line.strip_prefix("session.styleFile:") {
447            let path_str = rest.trim();
448            let path = std::path::Path::new(path_str);
449            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
450                if !name.is_empty() {
451                    return Some(name.to_string());
452                }
453            }
454        }
455    }
456    None
457}
458
459#[allow(dead_code)]
460pub(crate) fn parse_icewm_theme(content: &str) -> Option<String> {
461    for line in content.lines() {
462        let line = line.trim();
463        if let Some(rest) = line.strip_prefix("Theme=") {
464            let val = rest.trim().trim_matches('"').trim_matches('\'');
465            if let Some(idx) = val.find('/') {
466                let theme_name = &val[..idx];
467                if !theme_name.is_empty() {
468                    return Some(theme_name.to_string());
469                }
470            } else if !val.is_empty() {
471                return Some(val.to_string());
472            }
473        }
474    }
475    None
476}
477
478#[allow(dead_code)]
479pub(crate) fn parse_plasma_wallpaper(content: &str) -> Option<String> {
480    for line in content.lines() {
481        let line = line.trim();
482        if line.starts_with("Image=") {
483            let val = line.trim_start_matches("Image=").trim();
484            if let Some(cleaned) = clean_wallpaper_uri(val) {
485                return Some(cleaned);
486            }
487        } else if line.starts_with("usersWallpapers=") {
488            let val = line.trim_start_matches("usersWallpapers=").trim();
489            if let Some(first) = val.split(',').next() {
490                if let Some(cleaned) = clean_wallpaper_uri(first) {
491                    return Some(cleaned);
492                }
493            }
494        }
495    }
496    None
497}
498
499#[allow(dead_code)]
500pub(crate) fn parse_xfce_wallpaper(content: &str) -> Option<String> {
501    for line in content.lines() {
502        let line = line.trim();
503        if (line.contains("last-image") || line.contains("image-path")) && line.contains("value=") {
504            if let Some(start) = line.find("value=\"") {
505                let rest = &line[start + 7..];
506                if let Some(end) = rest.find('"') {
507                    let val = &rest[..end];
508                    if let Some(cleaned) = clean_wallpaper_uri(val) {
509                        return Some(cleaned);
510                    }
511                }
512            }
513        }
514    }
515    None
516}
517
518#[allow(dead_code)]
519pub(crate) fn parse_hyprpaper_wallpaper(content: &str) -> Option<String> {
520    for line in content.lines() {
521        let line = line.trim();
522        if line.starts_with('#') {
523            continue;
524        }
525        if line.starts_with("wallpaper") {
526            if let Some(pos) = line.find('=') {
527                let rest = line[pos + 1..].trim();
528                if let Some(idx) = rest.rfind(',') {
529                    let path = rest[idx + 1..].trim();
530                    if let Some(cleaned) = clean_wallpaper_uri(path) {
531                        return Some(cleaned);
532                    }
533                }
534            }
535        } else if line.starts_with("preload") {
536            if let Some(pos) = line.find('=') {
537                let path = line[pos + 1..].trim();
538                if let Some(cleaned) = clean_wallpaper_uri(path) {
539                    return Some(cleaned);
540                }
541            }
542        }
543    }
544    None
545}
546
547#[allow(dead_code)]
548pub(crate) fn parse_sway_wallpaper(content: &str) -> Option<String> {
549    for line in content.lines() {
550        let line = line.trim();
551        if line.starts_with('#') {
552            continue;
553        }
554        if line.starts_with("output ") && line.contains(" bg ") {
555            let parts: Vec<&str> = line.split_whitespace().collect();
556            if let Some(bg_idx) = parts.iter().position(|&p| p == "bg") {
557                if bg_idx + 1 < parts.len() {
558                    return clean_wallpaper_uri(parts[bg_idx + 1]);
559                }
560            }
561        }
562    }
563    None
564}
565
566#[allow(dead_code)]
567pub(crate) fn parse_feh_wallpaper(content: &str) -> Option<String> {
568    for line in content.lines() {
569        let line = line.trim();
570        if line.starts_with("feh ") {
571            if let Some(start) = line.find('\'') {
572                if let Some(end) = line[start + 1..].rfind('\'') {
573                    return clean_wallpaper_uri(&line[start + 1..start + 1 + end]);
574                }
575            }
576            if let Some(start) = line.find('"') {
577                if let Some(end) = line[start + 1..].rfind('"') {
578                    return clean_wallpaper_uri(&line[start + 1..start + 1 + end]);
579                }
580            }
581            let parts: Vec<&str> = line.split_whitespace().collect();
582            if let Some(last) = parts.last() {
583                if !last.starts_with('-') {
584                    return clean_wallpaper_uri(last);
585                }
586            }
587        }
588    }
589    None
590}
591
592#[allow(dead_code)]
593pub(crate) fn parse_nitrogen_wallpaper(content: &str) -> Option<String> {
594    for line in content.lines() {
595        let line = line.trim();
596        if line.starts_with("file=") {
597            return clean_wallpaper_uri(line.trim_start_matches("file="));
598        }
599    }
600    None
601}
602
603#[allow(dead_code)]
604pub(crate) fn parse_windows_theme_name(theme_path: &str) -> Option<String> {
605    let clean = theme_path.trim().trim_matches('"').trim_matches('\'');
606    let last_segment = clean.rsplit(['/', '\\']).next()?;
607    let stem = if let Some(idx) = last_segment.rfind('.') {
608        &last_segment[..idx]
609    } else {
610        last_segment
611    };
612    if stem.is_empty() {
613        return None;
614    }
615    match stem.to_lowercase().as_str() {
616        "aero" => Some("Aero".to_string()),
617        "dark" => Some("Dark".to_string()),
618        "light" => Some("Light".to_string()),
619        "custom" => Some("Custom".to_string()),
620        "windows" => Some("Windows".to_string()),
621        _ => Some(stem.to_string()),
622    }
623}
624
625pub(crate) fn detect_wm_theme(wm: Option<&str>, desktop: Option<&str>) -> Option<String> {
626    #[cfg(target_os = "linux")]
627    {
628        let wm_name = wm.map(|w| w.to_lowercase()).unwrap_or_default();
629        let de_name = desktop.map(|d| d.to_lowercase()).unwrap_or_default();
630        let home = dirs::home_dir();
631
632        // 1. KWin (KDE Plasma)
633        if wm_name.contains("kwin") || de_name.contains("kde") || de_name.contains("plasma") {
634            if let Some(ref h) = home {
635                let kwinrc = h.join(".config/kwinrc");
636                if let Ok(content) = std::fs::read_to_string(&kwinrc) {
637                    if let Some(theme) = parse_kwin_theme(&content) {
638                        return Some(theme);
639                    }
640                }
641            }
642            if let Some(kde_theme) =
643                get_kde_setting("widgetStyle").or_else(|| get_kde_setting("ColorScheme"))
644            {
645                return Some(kde_theme);
646            }
647        }
648
649        // 2. Mutter / GNOME / Budgie / Cinnamon
650        if wm_name.contains("mutter")
651            || de_name.contains("gnome")
652            || de_name.contains("budgie")
653            || de_name.contains("cinnamon")
654        {
655            if let Some(user_theme) =
656                query_gsettings("org.gnome.shell.extensions.user-theme", "name")
657            {
658                return Some(user_theme);
659            }
660            if let Some(wm_pref) = query_gsettings("org.gnome.desktop.wm.preferences", "theme") {
661                if !wm_pref.is_empty() && wm_pref != "Adwaita" {
662                    return Some(wm_pref);
663                }
664            }
665            if let Some(cinna_theme) =
666                query_gsettings("org.cinnamon.desktop.wm.preferences", "theme")
667            {
668                return Some(cinna_theme);
669            }
670            if let Some(gtk) = get_gtk_setting("gtk-theme-name")
671                .or_else(|| query_gsettings("org.gnome.desktop.interface", "gtk-theme"))
672            {
673                return Some(gtk);
674            }
675        }
676
677        // 3. Marco (MATE)
678        if wm_name.contains("marco") || de_name.contains("mate") {
679            if let Some(theme) = query_gsettings("org.mate.Marco.general", "theme") {
680                return Some(theme);
681            }
682        }
683
684        // 4. Xfwm4 (XFCE)
685        if wm_name.contains("xfwm") || de_name.contains("xfce") {
686            if let Some(ref h) = home {
687                let xfwm4_xml = h.join(".config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml");
688                if let Ok(content) = std::fs::read_to_string(&xfwm4_xml) {
689                    if let Some(theme) = parse_xfwm4_theme(&content) {
690                        return Some(theme);
691                    }
692                }
693            }
694        }
695
696        // 5. Openbox
697        if wm_name.contains("openbox") {
698            if let Some(ref h) = home {
699                for path in &[
700                    h.join(".config/openbox/rc.xml"),
701                    h.join(".config/openbox/lxde-rc.xml"),
702                    h.join(".config/openbox/lxqt-rc.xml"),
703                ] {
704                    if let Ok(content) = std::fs::read_to_string(path) {
705                        if let Some(theme) = parse_openbox_theme(&content) {
706                            return Some(theme);
707                        }
708                    }
709                }
710            }
711        }
712
713        // 6. Fluxbox
714        if wm_name.contains("fluxbox") {
715            if let Some(ref h) = home {
716                let init = h.join(".fluxbox/init");
717                if let Ok(content) = std::fs::read_to_string(&init) {
718                    if let Some(theme) = parse_fluxbox_theme(&content) {
719                        return Some(theme);
720                    }
721                }
722            }
723        }
724
725        // 7. IceWM
726        if wm_name.contains("icewm") {
727            if let Some(ref h) = home {
728                for path in &[h.join(".config/icewm/theme"), h.join(".icewm/theme")] {
729                    if let Ok(content) = std::fs::read_to_string(path) {
730                        if let Some(theme) = parse_icewm_theme(&content) {
731                            return Some(theme);
732                        }
733                    }
734                }
735            }
736        }
737
738        // Generic fallback to GTK/Qt theme if available
739        if let Some(gtk) = get_gtk_setting("gtk-theme-name")
740            .or_else(|| query_gsettings("org.gnome.desktop.interface", "gtk-theme"))
741        {
742            return Some(gtk);
743        }
744        if let Some(qt) = get_kde_setting("widgetStyle").or_else(|| get_kde_setting("ColorScheme"))
745        {
746            return Some(qt);
747        }
748
749        return None;
750    }
751
752    #[cfg(target_os = "macos")]
753    {
754        let _ = (wm, desktop);
755        return match crate::macos_ffi::get_macos_appearance() {
756            Some(style) => Some(format!("Aqua ({})", style)),
757            None => Some("Aqua (Light)".to_string()),
758        };
759    }
760
761    #[cfg(target_os = "windows")]
762    {
763        let _ = (wm, desktop);
764        use crate::win_reg;
765        let theme_path = win_reg::get_reg_string(
766            win_reg::HKEY_CURRENT_USER,
767            "Software\\Microsoft\\Windows\\CurrentVersion\\Themes",
768            "CurrentTheme",
769        );
770        let apps_light = win_reg::get_reg_u32(
771            win_reg::HKEY_CURRENT_USER,
772            "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
773            "AppsUseLightTheme",
774        );
775        let dark_suffix = match apps_light {
776            Some(0) => " (Dark)",
777            Some(_) => " (Light)",
778            None => "",
779        };
780
781        if let Some(path) = theme_path {
782            if let Some(name) = parse_windows_theme_name(&path) {
783                return Some(format!("{}{}", name, dark_suffix));
784            }
785        }
786        return Some(format!("Aero{}", dark_suffix));
787    }
788
789    #[allow(unreachable_code)]
790    None
791}
792
793pub(crate) fn detect_wallpaper(desktop: Option<&str>, wm: Option<&str>) -> Option<String> {
794    #[cfg(target_os = "linux")]
795    {
796        let de_name = desktop.map(|d| d.to_lowercase()).unwrap_or_default();
797        let wm_name = wm.map(|w| w.to_lowercase()).unwrap_or_default();
798        let home = dirs::home_dir();
799
800        // 1. GNOME / Cinnamon / Budgie / Unity / Niri
801        if de_name.contains("gnome")
802            || de_name.contains("budgie")
803            || de_name.contains("unity")
804            || wm_name.contains("mutter")
805            || wm_name.contains("niri")
806        {
807            if let Some(uri) = query_gsettings("org.gnome.desktop.background", "picture-uri-dark")
808                .or_else(|| query_gsettings("org.gnome.desktop.background", "picture-uri"))
809            {
810                if let Some(cleaned) = clean_wallpaper_uri(&uri) {
811                    if !cleaned.is_empty() {
812                        return Some(cleaned);
813                    }
814                }
815            }
816        }
817        if de_name.contains("cinnamon") {
818            if let Some(uri) = query_gsettings("org.cinnamon.desktop.background", "picture-uri") {
819                if let Some(cleaned) = clean_wallpaper_uri(&uri) {
820                    if !cleaned.is_empty() {
821                        return Some(cleaned);
822                    }
823                }
824            }
825        }
826
827        // 2. MATE
828        if de_name.contains("mate") || wm_name.contains("marco") {
829            if let Some(file) = query_gsettings("org.mate.background", "picture-filename") {
830                if let Some(cleaned) = clean_wallpaper_uri(&file) {
831                    if !cleaned.is_empty() {
832                        return Some(cleaned);
833                    }
834                }
835            }
836        }
837
838        // 3. KDE Plasma
839        if de_name.contains("kde") || de_name.contains("plasma") || wm_name.contains("kwin") {
840            if let Some(ref h) = home {
841                let appletsrc = h.join(".config/plasma-org.kde.plasma.desktop-appletsrc");
842                if let Ok(content) = std::fs::read_to_string(&appletsrc) {
843                    if let Some(wp) = parse_plasma_wallpaper(&content) {
844                        return Some(wp);
845                    }
846                }
847            }
848        }
849
850        // 4. XFCE
851        if de_name.contains("xfce") || wm_name.contains("xfwm") {
852            if let Some(ref h) = home {
853                let xfce_xml = h.join(".config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml");
854                if let Ok(content) = std::fs::read_to_string(&xfce_xml) {
855                    if let Some(wp) = parse_xfce_wallpaper(&content) {
856                        return Some(wp);
857                    }
858                }
859            }
860        }
861
862        // 5. Hyprpaper
863        if let Some(ref h) = home {
864            let conf = h.join(".config/hypr/hyprpaper.conf");
865            if let Ok(content) = std::fs::read_to_string(&conf) {
866                if let Some(wp) = parse_hyprpaper_wallpaper(&content) {
867                    return Some(wp);
868                }
869            }
870        }
871
872        // 6. Sway config
873        if let Some(ref h) = home {
874            for conf_path in &[h.join(".config/sway/config"), h.join(".sway/config")] {
875                if let Ok(content) = std::fs::read_to_string(conf_path) {
876                    if let Some(wp) = parse_sway_wallpaper(&content) {
877                        return Some(wp);
878                    }
879                }
880            }
881        }
882
883        // 7. Feh
884        if let Some(ref h) = home {
885            let fehbg = h.join(".fehbg");
886            if let Ok(content) = std::fs::read_to_string(&fehbg) {
887                if let Some(wp) = parse_feh_wallpaper(&content) {
888                    return Some(wp);
889                }
890            }
891        }
892
893        // 8. Nitrogen
894        if let Some(ref h) = home {
895            let n_cfg = h.join(".config/nitrogen/bg-saved.cfg");
896            if let Ok(content) = std::fs::read_to_string(&n_cfg) {
897                if let Some(wp) = parse_nitrogen_wallpaper(&content) {
898                    return Some(wp);
899                }
900            }
901        }
902
903        // 9. Generic gsettings fallback
904        if let Some(uri) = query_gsettings("org.gnome.desktop.background", "picture-uri-dark")
905            .or_else(|| query_gsettings("org.gnome.desktop.background", "picture-uri"))
906        {
907            if let Some(cleaned) = clean_wallpaper_uri(&uri) {
908                if !cleaned.is_empty() {
909                    return Some(cleaned);
910                }
911            }
912        }
913
914        return None;
915    }
916
917    #[cfg(target_os = "macos")]
918    {
919        let _ = (desktop, wm);
920        return crate::macos_ffi::get_macos_wallpaper();
921    }
922
923    #[cfg(target_os = "windows")]
924    {
925        let _ = (desktop, wm);
926        use crate::win_reg;
927        let wp = win_reg::get_reg_string(
928            win_reg::HKEY_CURRENT_USER,
929            "Control Panel\\Desktop",
930            "WallPaper",
931        );
932        return wp.and_then(|w| clean_wallpaper_uri(&w));
933    }
934
935    #[allow(unreachable_code)]
936    None
937}
938
939#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
940pub(crate) fn detect_ui_theme_and_fonts() -> (
941    Option<String>,
942    Option<String>,
943    Option<String>,
944    Option<String>,
945) {
946    (None, None, None, None)
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952
953    #[test]
954    fn test_parse_ini_key() {
955        let ini = "[Settings]\ngtk-theme-name=Adwaita-dark\ngtk-icon-theme-name=Papirus\n";
956        assert_eq!(
957            parse_ini_key(ini, "gtk-theme-name"),
958            Some("Adwaita-dark".to_string())
959        );
960        assert_eq!(
961            parse_ini_key(ini, "gtk-icon-theme-name"),
962            Some("Papirus".to_string())
963        );
964        assert_eq!(parse_ini_key(ini, "missing-key"), None);
965
966        // Quoted values
967        let ini_quoted = "font=\"DejaVu Sans 11\"\ncursor='Adwaita'\n";
968        assert_eq!(
969            parse_ini_key(ini_quoted, "font"),
970            Some("DejaVu Sans 11".to_string())
971        );
972        assert_eq!(
973            parse_ini_key(ini_quoted, "cursor"),
974            Some("Adwaita".to_string())
975        );
976
977        // Comments are skipped
978        let ini_comments = "# this is a comment\n; also a comment\nkey=value\n";
979        assert_eq!(
980            parse_ini_key(ini_comments, "key"),
981            Some("value".to_string())
982        );
983        assert_eq!(parse_ini_key(ini_comments, "#"), None);
984    }
985
986    #[test]
987    fn test_decode_percent_encoded() {
988        assert_eq!(decode_percent_encoded("hello%20world"), "hello world");
989        assert_eq!(
990            decode_percent_encoded("/usr/share/wallpapers/My%20Wallpaper.png"),
991            "/usr/share/wallpapers/My Wallpaper.png"
992        );
993        assert_eq!(decode_percent_encoded("no_percent"), "no_percent");
994    }
995
996    #[test]
997    fn test_clean_wallpaper_uri() {
998        assert_eq!(
999            clean_wallpaper_uri("'file:///usr/share/backgrounds/day.jpg'"),
1000            Some("/usr/share/backgrounds/day.jpg".to_string())
1001        );
1002        assert_eq!(
1003            clean_wallpaper_uri("\"file:///home/user/Pictures/My%20Wallpaper.png\""),
1004            Some("/home/user/Pictures/My Wallpaper.png".to_string())
1005        );
1006        assert_eq!(
1007            clean_wallpaper_uri("C:\\Users\\user\\Pictures\\Wallpaper.jpg"),
1008            Some("C:\\Users\\user\\Pictures\\Wallpaper.jpg".to_string())
1009        );
1010        assert_eq!(clean_wallpaper_uri(""), None);
1011        assert_eq!(clean_wallpaper_uri("''"), None);
1012        assert_eq!(clean_wallpaper_uri("\"\""), None);
1013    }
1014
1015    #[test]
1016    fn test_parse_kwin_theme() {
1017        let kwinrc = r#"
1018[org.kde.kdecoration2]
1019BorderSize=Normal
1020BorderSizeAuto=true
1021ButtonsOnLeft=M
1022ButtonsOnRight=IAX
1023library=org.kde.kwin.aurorae
1024theme=__aurorae__svg__Nordic
1025"#;
1026        assert_eq!(parse_kwin_theme(kwinrc), Some("Nordic".to_string()));
1027
1028        let kwinrc_breeze = r#"
1029[org.kde.kdecoration2]
1030library=org.kde.breeze
1031theme=Breeze
1032"#;
1033        assert_eq!(parse_kwin_theme(kwinrc_breeze), Some("Breeze".to_string()));
1034
1035        let kwinrc_qml = r#"
1036[org.kde.kdecoration2]
1037theme=qml_Sweet-Dark
1038"#;
1039        assert_eq!(parse_kwin_theme(kwinrc_qml), Some("Sweet-Dark".to_string()));
1040    }
1041
1042    #[test]
1043    fn test_parse_xfwm4_theme() {
1044        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1045<channel name="xfwm4" version="1.0">
1046  <property name="general" type="empty">
1047    <property name="theme" type="string" value="Greybird"/>
1048    <property name="title_font" type="string" value="Sans 9"/>
1049  </property>
1050</channel>
1051"#;
1052        assert_eq!(parse_xfwm4_theme(xml), Some("Greybird".to_string()));
1053    }
1054
1055    #[test]
1056    fn test_parse_openbox_theme() {
1057        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1058<openbox_config xmlns="http://openbox.org/3.4/rc">
1059  <theme>
1060    <name>Clearlooks</name>
1061    <titleLayout>NLIMC</titleLayout>
1062  </theme>
1063</openbox_config>
1064"#;
1065        assert_eq!(parse_openbox_theme(xml), Some("Clearlooks".to_string()));
1066    }
1067
1068    #[test]
1069    fn test_parse_fluxbox_theme() {
1070        let init = "session.styleFile: /usr/share/fluxbox/styles/bloody\nsession.screen0.toolbar.tools: prevworkspace, workspacename\n";
1071        assert_eq!(parse_fluxbox_theme(init), Some("bloody".to_string()));
1072    }
1073
1074    #[test]
1075    fn test_parse_icewm_theme() {
1076        let cfg = "Theme=\"Adwaita/default.theme\"\n";
1077        assert_eq!(parse_icewm_theme(cfg), Some("Adwaita".to_string()));
1078    }
1079
1080    #[test]
1081    fn test_parse_plasma_wallpaper() {
1082        let appletsrc = r#"
1083[Containments][1][Applets][2][Configuration][General]
1084Image=file:///usr/share/wallpapers/Next/contents/images/3840x2160.png
1085usersWallpapers=file:///usr/share/wallpapers/Next/contents/images/3840x2160.png
1086"#;
1087        assert_eq!(
1088            parse_plasma_wallpaper(appletsrc),
1089            Some("/usr/share/wallpapers/Next/contents/images/3840x2160.png".to_string())
1090        );
1091    }
1092
1093    #[test]
1094    fn test_parse_xfce_wallpaper() {
1095        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1096<channel name="xfce4-desktop" version="1.0">
1097  <property name="backdrop" type="empty">
1098    <property name="screen0" type="empty">
1099      <property name="monitor0" type="empty">
1100        <property name="last-image" type="string" value="/usr/share/backgrounds/xfce/xfce-teal.jpg"/>
1101      </property>
1102    </property>
1103  </property>
1104</channel>
1105"#;
1106        assert_eq!(
1107            parse_xfce_wallpaper(xml),
1108            Some("/usr/share/backgrounds/xfce/xfce-teal.jpg".to_string())
1109        );
1110    }
1111
1112    #[test]
1113    fn test_parse_hyprpaper_wallpaper() {
1114        let conf = r#"
1115preload = /home/user/Pictures/wall.png
1116wallpaper = DP-1,/home/user/Pictures/wall.png
1117"#;
1118        assert_eq!(
1119            parse_hyprpaper_wallpaper(conf),
1120            Some("/home/user/Pictures/wall.png".to_string())
1121        );
1122    }
1123
1124    #[test]
1125    fn test_parse_sway_wallpaper() {
1126        let conf = r#"
1127output * bg /usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png fill
1128"#;
1129        assert_eq!(
1130            parse_sway_wallpaper(conf),
1131            Some("/usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png".to_string())
1132        );
1133    }
1134
1135    #[test]
1136    fn test_parse_feh_wallpaper() {
1137        let feh = "feh --no-fehbg --bg-fill '/home/user/Pictures/nature.jpg'";
1138        assert_eq!(
1139            parse_feh_wallpaper(feh),
1140            Some("/home/user/Pictures/nature.jpg".to_string())
1141        );
1142    }
1143
1144    #[test]
1145    fn test_parse_nitrogen_wallpaper() {
1146        let cfg = "[xin_0]\nfile=/home/user/wallpapers/sunset.png\nmode=4\n";
1147        assert_eq!(
1148            parse_nitrogen_wallpaper(cfg),
1149            Some("/home/user/wallpapers/sunset.png".to_string())
1150        );
1151    }
1152
1153    #[test]
1154    fn test_parse_windows_theme_name() {
1155        assert_eq!(
1156            parse_windows_theme_name(r"C:\Windows\resources\Themes\aero.theme"),
1157            Some("Aero".to_string())
1158        );
1159        assert_eq!(
1160            parse_windows_theme_name(r"C:\Windows\resources\Themes\dark.theme"),
1161            Some("Dark".to_string())
1162        );
1163        assert_eq!(
1164            parse_windows_theme_name(
1165                r"C:\Users\User\AppData\Local\Microsoft\Windows\Themes\Custom.theme"
1166            ),
1167            Some("Custom".to_string())
1168        );
1169    }
1170}