1#[cxx_qt::bridge]
16pub mod ffi {
17 #[auto_cxx_name]
18 extern "RustQt" {
19 #[qobject]
21 #[qml_element]
22 type Desktop = super::DesktopRust;
23
24 #[qinvokable]
28 fn window_buttons(self: &Desktop) -> QString;
29 }
30
31 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
67pub fn icons() {
71 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 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
90fn 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 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
113fn 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 std::iter::once(home).chain(std::env::split_paths(&dirs)).map(|d| d.join("icons")).filter(|d| d.is_dir()).collect()
121}
122
123#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum WindowButton {
136 Minimize,
137 Maximize,
138 Close,
139}
140
141impl WindowButton {
142 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
155pub struct ButtonLayout {
156 pub left: Vec<WindowButton>,
157 pub right: Vec<WindowButton>,
158}
159
160impl ButtonLayout {
161 fn on_the_right() -> Self {
164 ButtonLayout { left: Vec::new(), right: vec![WindowButton::Minimize, WindowButton::Maximize, WindowButton::Close] }
165 }
166
167 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 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
191pub 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#[cfg(not(target_os = "android"))]
201pub fn warm_up() {
202 std::thread::spawn(|| {
203 window_buttons();
204 });
205}
206
207fn 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 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 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 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 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 if on(&["gnome"]) {
247 return ButtonLayout { left: Vec::new(), right: vec![Close] };
248 }
249 }
250 ButtonLayout::on_the_right()
251}
252
253fn 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
264fn 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
273fn 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
293pub 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 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
321pub 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
337pub 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
362pub 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 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 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 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 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}