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#[cfg(target_os = "linux")]
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#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
308pub(crate) fn detect_ui_theme_and_fonts() -> (
309    Option<String>,
310    Option<String>,
311    Option<String>,
312    Option<String>,
313) {
314    (None, None, None, None)
315}
316
317#[cfg(all(test, target_os = "linux"))]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_parse_ini_key() {
323        let ini = "[Settings]\ngtk-theme-name=Adwaita-dark\ngtk-icon-theme-name=Papirus\n";
324        assert_eq!(
325            parse_ini_key(ini, "gtk-theme-name"),
326            Some("Adwaita-dark".to_string())
327        );
328        assert_eq!(
329            parse_ini_key(ini, "gtk-icon-theme-name"),
330            Some("Papirus".to_string())
331        );
332        assert_eq!(parse_ini_key(ini, "missing-key"), None);
333
334        // Quoted values
335        let ini_quoted = "font=\"DejaVu Sans 11\"\ncursor='Adwaita'\n";
336        assert_eq!(
337            parse_ini_key(ini_quoted, "font"),
338            Some("DejaVu Sans 11".to_string())
339        );
340        assert_eq!(
341            parse_ini_key(ini_quoted, "cursor"),
342            Some("Adwaita".to_string())
343        );
344
345        // Comments are skipped
346        let ini_comments = "# this is a comment\n; also a comment\nkey=value\n";
347        assert_eq!(
348            parse_ini_key(ini_comments, "key"),
349            Some("value".to_string())
350        );
351        assert_eq!(parse_ini_key(ini_comments, "#"), None);
352    }
353}