1#[cxx_qt::bridge]
17pub mod ffi {
18 #[auto_cxx_name]
19 extern "RustQt" {
20 #[qobject]
22 #[qml_element]
23 type Desktop = super::DesktopRust;
24
25 #[qinvokable]
29 fn window_buttons(self: &Desktop) -> QString;
30 }
31
32 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
73unsafe extern "C" {
75 fn sioul_window_dark() -> bool;
79}
80
81static DESKTOP_THEME: OnceLock<String> = OnceLock::new();
84
85static FOLLOWED: Mutex<Option<(bool, bool)>> = Mutex::new(None);
87
88pub 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 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
102pub 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 if desktop.is_empty() || chosen {
121 ffi::QIcon::set_theme_name(&bundled);
122 } else {
123 ffi::QIcon::set_theme_name(&QString::default());
124 }
125 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
138pub fn has_icon(name: &str) -> bool {
141 ffi::QIcon::has_theme_icon(&QString::from(name))
142}
143
144pub fn theme_in_use() -> String {
146 ffi::QIcon::theme_name().to_string()
147}
148
149fn 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 std::iter::once(home).chain(std::env::split_paths(&dirs)).map(|d| d.join("icons")).filter(|d| d.is_dir()).collect()
157}
158
159#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum WindowButton {
172 Minimize,
173 Maximize,
174 Close,
175}
176
177impl WindowButton {
178 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
191pub struct ButtonLayout {
192 pub left: Vec<WindowButton>,
193 pub right: Vec<WindowButton>,
194}
195
196impl ButtonLayout {
197 fn on_the_right() -> Self {
200 ButtonLayout { left: Vec::new(), right: vec![WindowButton::Minimize, WindowButton::Maximize, WindowButton::Close] }
201 }
202
203 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 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
227pub 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#[cfg(not(target_os = "android"))]
237pub fn warm_up() {
238 std::thread::spawn(|| {
239 window_buttons();
240 });
241}
242
243fn 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 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 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 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 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 if on(&["gnome"]) {
283 return ButtonLayout { left: Vec::new(), right: vec![Close] };
284 }
285 }
286 ButtonLayout::on_the_right()
287}
288
289fn 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
300fn 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
309fn 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
329pub 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 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
357pub 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
373pub 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
398pub 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 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 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 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 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}