Skip to main content

sioul_app/
desktop.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The desktop's icon theme. Plasma, GNOME and others name one and say where
5//! icons live; elsewhere (a bare session, the offscreen platform, Windows,
6//! macOS) Qt knows of neither, and icons would be blank. The Breeze icons
7//! Sioul uses ship inside it (`tools/bundle-icons.py`), light and dark, and
8//! stand in for whatever the desktop lacks.
9//!
10//! And where the desktop puts a window's buttons, for the title bar Sioul
11//! draws itself on a computer (qml/TitleBar.qml): KDE Plasma says it in
12//! kwinrc, GNOME and its kin in their `button-layout` setting, Xfce in
13//! xfwm4's; macOS puts them on the left, Windows on the right.
14
15#[cxx_qt::bridge]
16pub mod ffi {
17    #[auto_cxx_name]
18    extern "RustQt" {
19        /// What the desktop says of windows, to QML.
20        #[qobject]
21        #[qml_element]
22        type Desktop = super::DesktopRust;
23
24        /// The window's buttons on each side of its title bar, in the
25        /// system's order, as JSON: `{"left": [...], "right": [...]}`, each
26        /// "minimize", "maximize" or "close". Read once, as Sioul starts.
27        #[qinvokable]
28        fn window_buttons(self: &Desktop) -> QString;
29    }
30
31    // SAFETY: cxx checks these declarations against the headers they include, when it builds; the types are cxx-qt-lib's.
32    unsafe extern "C++" {
33        include!("cxx-qt-lib/qstring.h");
34        type QString = cxx_qt_lib::QString;
35
36        include!("cxx-qt-lib/qstringlist.h");
37        type QStringList = cxx_qt_lib::QStringList;
38
39        include!(<QtGui/QIcon>);
40        type QIcon;
41
42        #[Self = "QIcon"]
43        #[rust_name = "theme_name"]
44        fn themeName() -> QString;
45
46        #[Self = "QIcon"]
47        #[rust_name = "set_theme_name"]
48        fn setThemeName(name: &QString);
49
50        #[Self = "QIcon"]
51        #[rust_name = "set_fallback_theme_name"]
52        fn setFallbackThemeName(name: &QString);
53
54        #[Self = "QIcon"]
55        #[rust_name = "theme_search_paths"]
56        fn themeSearchPaths() -> QStringList;
57
58        #[Self = "QIcon"]
59        #[rust_name = "set_theme_search_paths"]
60        fn setThemeSearchPaths(paths: &QStringList);
61    }
62}
63
64use cxx_qt_lib::{QList, QString, QStringList};
65use std::path::PathBuf;
66
67/// The bundled icons stand in for those the desktop's theme lacks, and for the
68/// theme itself when the desktop names none; the folders of the XDG base
69/// directory specification are searched too.
70pub fn icons() {
71    // Colours chosen in Sioul (or by SIOUL_THEME) take the bundled icons drawn for them.
72    let chosen = std::env::var("SIOUL_THEME").ok().filter(|t| !t.is_empty()).or_else(|| crate::backend::load_config().theme).filter(|t| t == "dark" || t == "light");
73    let bundled = QString::from(if chosen.as_ref().map_or_else(dark, |t| t == "dark") { "sioul-dark" } else { "sioul" });
74    // Each folder once: Qt looks for every icon in each, and has ":/icons" already.
75    let mut paths: QList<QString> = QList::default();
76    let qt = ffi::QIcon::theme_search_paths();
77    let ours = std::iter::once(":/icons".to_owned()).chain(xdg_icon_folders().into_iter().map(|folder| folder.display().to_string()));
78    for path in qt.iter().cloned().chain(ours.map(|path| QString::from(&path))) {
79        if !paths.contains(&path) {
80            paths.append(path);
81        }
82    }
83    ffi::QIcon::set_theme_search_paths(&QStringList::from(&paths));
84    ffi::QIcon::set_fallback_theme_name(&bundled);
85    if ffi::QIcon::theme_name().is_empty() || chosen.is_some() {
86        ffi::QIcon::set_theme_name(&bundled);
87    }
88}
89
90/// Whether the system shows its windows dark, where Qt has no icon theme to follow it.
91fn dark() -> bool {
92    #[cfg(target_os = "macos")]
93    {
94        std::process::Command::new("defaults").args(["read", "-g", "AppleInterfaceStyle"]).output().is_ok_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "Dark")
95    }
96    #[cfg(windows)]
97    {
98        use std::os::windows::process::CommandExt;
99        // HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize, AppsUseLightTheme = 0;
100        // asked without a console window flashing up (CREATE_NO_WINDOW).
101        std::process::Command::new("reg")
102            .args(["query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "/v", "AppsUseLightTheme"])
103            .creation_flags(0x0800_0000)
104            .output()
105            .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("0x0"))
106    }
107    #[cfg(not(any(windows, target_os = "macos")))]
108    {
109        false
110    }
111}
112
113/// `$XDG_DATA_HOME/icons`, then `<dir>/icons` for each of `$XDG_DATA_DIRS`.
114fn xdg_icon_folders() -> Vec<PathBuf> {
115    let home = std::env::var_os("XDG_DATA_HOME")
116        .filter(|v| !v.is_empty())
117        .map_or_else(|| sioul_core::config::expand_home("~/.local/share"), PathBuf::from);
118    let dirs = std::env::var_os("XDG_DATA_DIRS").filter(|v| !v.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
119    // ":" between folders, ";" on Windows, where ":" follows a drive letter.
120    std::iter::once(home).chain(std::env::split_paths(&dirs)).map(|d| d.join("icons")).filter(|d| d.is_dir()).collect()
121}
122
123/// The object behind QML's `Desktop`: nothing of its own.
124#[derive(Default)]
125pub struct DesktopRust;
126
127impl ffi::Desktop {
128    fn window_buttons(&self) -> QString {
129        QString::from(&window_buttons().json())
130    }
131}
132
133/// One of the window's buttons that Sioul's title bar draws.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum WindowButton {
136    Minimize,
137    Maximize,
138    Close,
139}
140
141impl WindowButton {
142    /// Its name, as QML reads it and GNOME writes it.
143    fn id(self) -> &'static str {
144        match self {
145            WindowButton::Minimize => "minimize",
146            WindowButton::Maximize => "maximize",
147            WindowButton::Close => "close",
148        }
149    }
150}
151
152/// The window's buttons on the left of its title bar and on its right, each
153/// side in its order.
154#[derive(Clone, Debug, Default, PartialEq, Eq)]
155pub struct ButtonLayout {
156    pub left: Vec<WindowButton>,
157    pub right: Vec<WindowButton>,
158}
159
160impl ButtonLayout {
161    /// Minimize, maximize and close on the right: Windows's, KDE Plasma's as
162    /// it comes (its help button aside), and Sioul's where the system says nothing.
163    fn on_the_right() -> Self {
164        ButtonLayout { left: Vec::new(), right: vec![WindowButton::Minimize, WindowButton::Maximize, WindowButton::Close] }
165    }
166
167    /// Each button once, where it first comes.
168    fn once(self) -> Self {
169        let mut seen = Vec::new();
170        let mut keep = |side: Vec<WindowButton>| -> Vec<WindowButton> {
171            side.into_iter()
172                .filter(|button| {
173                    let new = !seen.contains(button);
174                    seen.push(*button);
175                    new
176                })
177                .collect()
178        };
179        let left = keep(self.left);
180        let right = keep(self.right);
181        ButtonLayout { left, right }
182    }
183
184    /// As QML reads it: `{"left": ["close", …], "right": […]}`.
185    fn json(&self) -> String {
186        let ids = |side: &[WindowButton]| side.iter().map(|button| button.id()).collect::<Vec<_>>();
187        serde_json::json!({ "left": ids(&self.left), "right": ids(&self.right) }).to_string()
188    }
189}
190
191/// Where the system puts the window's buttons, read once: on a thread of its
192/// own as Sioul starts (`warm_up`), GNOME's setting being a program to ask.
193pub fn window_buttons() -> &'static ButtonLayout {
194    static LAYOUT: std::sync::OnceLock<ButtonLayout> = std::sync::OnceLock::new();
195    LAYOUT.get_or_init(|| system_layout().once())
196}
197
198/// Starts reading where the system puts the window's buttons, for the title
199/// bar to find it read when it is made. A phone has no title bar.
200#[cfg(not(target_os = "android"))]
201pub fn warm_up() {
202    std::thread::spawn(|| {
203        window_buttons();
204    });
205}
206
207/// What the system says. SIOUL_BUTTON_LAYOUT, written as GNOME writes it
208/// ("close,minimize,maximize:" puts them on the left), tries a layout the
209/// desktop does not have.
210fn system_layout() -> ButtonLayout {
211    use WindowButton::{Close, Maximize, Minimize};
212    if let Some(tried) = std::env::var("SIOUL_BUTTON_LAYOUT").ok().and_then(|value| gnome_layout(&value)) {
213        return tried;
214    }
215    // macOS: close, minimize and zoom, on the left.
216    if cfg!(target_os = "macos") {
217        return ButtonLayout { left: vec![Close, Minimize, Maximize], right: Vec::new() };
218    }
219    if cfg!(any(windows, target_os = "android")) {
220        return ButtonLayout::on_the_right();
221    }
222    // Linux and the BSDs: the desktop's own setting; "ubuntu:GNOME", "X-Cinnamon".
223    let current = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().to_lowercase();
224    let desktops: Vec<&str> = current.split(':').map(str::trim).collect();
225    let on = |names: &[&str]| desktops.iter().any(|desktop| names.contains(desktop));
226    if on(&["kde"]) {
227        return kde_layout();
228    }
229    if on(&["xfce"]) {
230        // Xfce's own, as it comes: its menu on the left, then minimize, maximize and close.
231        return command("xfconf-query", &["-c", "xfwm4", "-p", "/general/button_layout"]).and_then(|value| xfwm_layout(&value)).unwrap_or_else(|| ButtonLayout::on_the_right());
232    }
233    if on(&["gnome", "unity", "budgie", "pantheon", "x-cinnamon", "cinnamon", "mate"]) {
234        // GNOME's setting, which its kin read too; Cinnamon and MATE keep their own first.
235        let schemas: &[&str] = if on(&["x-cinnamon", "cinnamon"]) {
236            &["org.cinnamon.desktop.wm.preferences", "org.gnome.desktop.wm.preferences"]
237        } else if on(&["mate"]) {
238            &["org.mate.Marco.general", "org.gnome.desktop.wm.preferences"]
239        } else {
240            &["org.gnome.desktop.wm.preferences"]
241        };
242        if let Some(layout) = schemas.iter().find_map(|schema| command("gsettings", &["get", schema, "button-layout"]).and_then(|value| gnome_layout(&value))) {
243            return layout;
244        }
245        // GNOME's own, as it comes: close alone, on the right.
246        if on(&["gnome"]) {
247            return ButtonLayout { left: Vec::new(), right: vec![Close] };
248        }
249    }
250    ButtonLayout::on_the_right()
251}
252
253/// What a program prints, trimmed; None when it is missing or fails.
254fn command(program: &str, args: &[&str]) -> Option<String> {
255    let output = std::process::Command::new(program)
256        .args(args)
257        .stdin(std::process::Stdio::null())
258        .stderr(std::process::Stdio::null())
259        .output()
260        .ok()?;
261    output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
262}
263
264/// Plasma's buttons (kwinrc, `[org.kde.kdecoration2]`): yours, else your
265/// global theme's, else the system's, else Plasma's own, "MS" on the left
266/// (the window's menu, on all desktops) and "HIAX" on the right.
267fn kde_layout() -> ButtonLayout {
268    let files: Vec<String> = kwinrc_files().iter().filter_map(|path| std::fs::read_to_string(path).ok()).collect();
269    let read = |key: &str, plasma: &str| files.iter().find_map(|text| kconfig_value(text, "org.kde.kdecoration2", key)).unwrap_or_else(|| plasma.to_string());
270    ButtonLayout { left: kde_buttons(&read("ButtonsOnLeft", "MS")), right: kde_buttons(&read("ButtonsOnRight", "HIAX")) }
271}
272
273/// The kwinrc files, the strongest first: in your configuration folder (and
274/// the host's, from a Flatpak, whose own is apart), your global theme's
275/// defaults beside it (kdedefaults), then the system's.
276fn kwinrc_files() -> Vec<PathBuf> {
277    let mut homes: Vec<PathBuf> = Vec::new();
278    let named = [std::env::var_os("XDG_CONFIG_HOME"), std::env::var_os("HOST_XDG_CONFIG_HOME")].into_iter().flatten().filter(|v| !v.is_empty()).map(PathBuf::from);
279    for home in named.chain([sioul_core::config::expand_home("~/.config")]) {
280        if !homes.contains(&home) {
281            homes.push(home);
282        }
283    }
284    let dirs = std::env::var_os("XDG_CONFIG_DIRS").filter(|v| !v.is_empty()).unwrap_or_else(|| "/etc/xdg".into());
285    homes
286        .iter()
287        .map(|home| home.join("kwinrc"))
288        .chain(homes.iter().map(|home| home.join("kdedefaults").join("kwinrc")))
289        .chain(std::env::split_paths(&dirs).map(|dir| dir.join("kwinrc")))
290        .collect()
291}
292
293/// The value of `key` in `[group]` of a KConfig file (kwinrc), the last one
294/// written there. A key marked immutable or to expand (`Key[$i]`, `Key[$e]`)
295/// counts, a translation (`Key[fr]`) does not, one marked deleted
296/// (`Key[$d]`) is not set. None when it is not there.
297pub fn kconfig_value(text: &str, group: &str, key: &str) -> Option<String> {
298    let header = format!("[{group}]");
299    let mut inside = false;
300    let mut value = None;
301    for line in text.lines().map(str::trim) {
302        if line.starts_with('[') {
303            // "[group]", or "[group][$i]", immutable.
304            inside = line.strip_prefix(header.as_str()).is_some_and(|rest| rest.is_empty() || rest.starts_with("[$"));
305            continue;
306        }
307        if !inside || line.starts_with('#') {
308            continue;
309        }
310        let Some((name, written)) = line.split_once('=') else { continue };
311        let name = name.trim();
312        let (bare, marks) = name.split_once('[').map_or((name, ""), |(bare, marks)| (bare.trim_end(), marks));
313        if bare != key || !(marks.is_empty() || marks.starts_with('$')) {
314            continue;
315        }
316        value = (!marks.contains('d')).then(|| written.trim().to_string());
317    }
318    value
319}
320
321/// Plasma's letters for the window's buttons (kwinrc's ButtonsOnLeft and
322/// ButtonsOnRight), those Sioul's bar draws: I minimize, A maximize, X close.
323/// The others have no place there: M the window's menu, S on all desktops, H
324/// help, F keep above, B keep below, L shade, N the application's menu, _ a space.
325pub fn kde_buttons(letters: &str) -> Vec<WindowButton> {
326    letters
327        .chars()
328        .filter_map(|letter| match letter {
329            'I' => Some(WindowButton::Minimize),
330            'A' => Some(WindowButton::Maximize),
331            'X' => Some(WindowButton::Close),
332            _ => None,
333        })
334        .collect()
335}
336
337/// A `button-layout` as GNOME writes it, bare or as gsettings prints it
338/// ('appmenu:minimize,maximize,close'): the left side's buttons before the
339/// colon, the right's after it; without a colon, all on the left, as GNOME
340/// reads it. "appmenu", "menu", "icon" and "spacer" have no place in Sioul's
341/// bar. None for an empty value.
342pub fn gnome_layout(value: &str) -> Option<ButtonLayout> {
343    let value = value.trim();
344    let value = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')).unwrap_or(value).trim();
345    if value.is_empty() {
346        return None;
347    }
348    let (left, right) = value.split_once(':').unwrap_or((value, ""));
349    let names = |side: &str| -> Vec<WindowButton> {
350        side.split(',')
351            .filter_map(|name| match name.trim() {
352                "minimize" => Some(WindowButton::Minimize),
353                "maximize" => Some(WindowButton::Maximize),
354                "close" => Some(WindowButton::Close),
355                _ => None,
356            })
357            .collect()
358    };
359    Some(ButtonLayout { left: names(left), right: names(right) })
360}
361
362/// xfwm4's `button_layout` (Xfce), "O|HMC": the left side's letters before
363/// the bar, which stands for the title, the right's after it. H minimize (it
364/// hides the window), M maximize, C close; O the window's menu, S shade and T
365/// on all workspaces have no place in Sioul's bar. None without the bar.
366pub fn xfwm_layout(value: &str) -> Option<ButtonLayout> {
367    let (left, right) = value.trim().split_once('|')?;
368    let letters = |side: &str| -> Vec<WindowButton> {
369        side.chars()
370            .filter_map(|letter| match letter {
371                'H' => Some(WindowButton::Minimize),
372                'M' => Some(WindowButton::Maximize),
373                'C' => Some(WindowButton::Close),
374                _ => None,
375            })
376            .collect()
377    };
378    Some(ButtonLayout { left: letters(left), right: letters(right) })
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use WindowButton::{Close, Maximize, Minimize};
385
386    fn sides(left: &[WindowButton], right: &[WindowButton]) -> ButtonLayout {
387        ButtonLayout { left: left.to_vec(), right: right.to_vec() }
388    }
389
390    #[test]
391    fn plasma_letters() {
392        // Plasma's own: the menu and on all desktops on the left, help, minimize, maximize, close on the right.
393        assert_eq!(kde_buttons("MS"), Vec::<WindowButton>::new());
394        assert_eq!(kde_buttons("HIAX"), vec![Minimize, Maximize, Close]);
395        assert_eq!(kde_buttons("XIA"), vec![Close, Minimize, Maximize]);
396        assert_eq!(kde_buttons("M_SFBLNX"), vec![Close]);
397        assert_eq!(kde_buttons(""), Vec::<WindowButton>::new());
398    }
399
400    #[test]
401    fn kwinrc_values() {
402        let kwinrc = "[Desktops]\nNumber=2\n\n[org.kde.kdecoration2]\nBorderSize=NoSides\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n\n[Windows]\nButtonsOnLeft=MS\n";
403        assert_eq!(kconfig_value(kwinrc, "org.kde.kdecoration2", "ButtonsOnLeft").as_deref(), Some("XIA"));
404        assert_eq!(kconfig_value(kwinrc, "org.kde.kdecoration2", "ButtonsOnRight").as_deref(), Some(""));
405        assert_eq!(kconfig_value(kwinrc, "org.kde.kdecoration2", "Missing"), None);
406        assert_eq!(kconfig_value(kwinrc, "Desktops", "ButtonsOnLeft"), None);
407        assert_eq!(kconfig_value("", "org.kde.kdecoration2", "ButtonsOnLeft"), None);
408        // Immutable counts, a translation does not, deleted unsets; the last one written wins.
409        let marked = "[org.kde.kdecoration2][$i]\nButtonsOnLeft[$i]=IAX\nButtonsOnRight[fr]=X\n# ButtonsOnRight=IX\n[org.kde.kdecoration2]\nButtonsOnLeft = MS \nButtonsOnRight[$d]=\n";
410        assert_eq!(kconfig_value(marked, "org.kde.kdecoration2", "ButtonsOnLeft").as_deref(), Some("MS"));
411        assert_eq!(kconfig_value(marked, "org.kde.kdecoration2", "ButtonsOnRight"), None);
412        // A group whose name only begins the same is another group.
413        assert_eq!(kconfig_value("[org.kde.kdecoration2.other]\nButtonsOnLeft=X\n", "org.kde.kdecoration2", "ButtonsOnLeft"), None);
414    }
415
416    #[test]
417    fn gnome_values() {
418        assert_eq!(gnome_layout("'appmenu:close'"), Some(sides(&[], &[Close])));
419        assert_eq!(gnome_layout("appmenu:minimize,maximize,close"), Some(sides(&[], &[Minimize, Maximize, Close])));
420        assert_eq!(gnome_layout("'close,minimize,maximize:'\n"), Some(sides(&[Close, Minimize, Maximize], &[])));
421        assert_eq!(gnome_layout("close:spacer,menu"), Some(sides(&[Close], &[])));
422        assert_eq!(gnome_layout(" icon , close : maximize "), Some(sides(&[Close], &[Maximize])));
423        // Without a colon, all on the left; nothing named, no buttons.
424        assert_eq!(gnome_layout("close,minimize"), Some(sides(&[Close, Minimize], &[])));
425        assert_eq!(gnome_layout("appmenu:"), Some(sides(&[], &[])));
426        assert_eq!(gnome_layout("''"), None);
427        assert_eq!(gnome_layout(""), None);
428    }
429
430    #[test]
431    fn xfwm_values() {
432        assert_eq!(xfwm_layout("O|HMC"), Some(sides(&[], &[Minimize, Maximize, Close])));
433        assert_eq!(xfwm_layout("CHM|\n"), Some(sides(&[Close, Minimize, Maximize], &[])));
434        assert_eq!(xfwm_layout("OTS|HC"), Some(sides(&[], &[Minimize, Close])));
435        assert_eq!(xfwm_layout("HMC"), None);
436    }
437
438    #[test]
439    fn each_button_once_as_qml_reads_them() {
440        let layout = sides(&[Close, Minimize], &[Minimize, Maximize, Close]).once();
441        assert_eq!(layout, sides(&[Close, Minimize], &[Maximize]));
442        assert_eq!(layout.json(), r#"{"left":["close","minimize"],"right":["maximize"]}"#);
443        assert_eq!(ButtonLayout::on_the_right().json(), r#"{"left":[],"right":["minimize","maximize","close"]}"#);
444        assert_eq!(sides(&[], &[]).json(), r#"{"left":[],"right":[]}"#);
445    }
446}