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