From e56b2f12205277d54ae0c01429e9a9b369cbac0b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:37:27 -0500 Subject: [PATCH] craft-window: frame policy v2: the app's bar is the title bar by default * craft-window: frame policy v2: integrated title bar by default, desktop and backend detection Add the policy module: Backend, Desktop::detect, TitleBarPref, Os, Frame {Native, Inset, Integrated}, pure decide, decide_for and apply. Caption layouts come from GNOME gsettings, kwinrc and GTK settings.ini parsers. Choice::probe, the root Frame and decorate keep their behaviour. * craft-window: review fixes for the frame policy Desktop gains icon_theme, Decision gains desktop, Backend::from_env, GNOME layout falls back to settings.ini, compositor socket variables mark tiling, GTK_THEME wins over settings.ini, layout is read only for an integrated frame, Layout::mirrored. * craft-window: CRAFT_TITLEBAR applies when the app's own variable is unset --------- Co-authored-by: aiongg --- crates/window/README.md | 72 +++++--- crates/window/src/desktop.rs | 130 +++++++++++++ crates/window/src/layout.rs | 234 ++++++++++++++++++++++++ crates/window/src/lib.rs | 34 +++- crates/window/src/policy.rs | 345 +++++++++++++++++++++++++++++++++++ 5 files changed, 780 insertions(+), 35 deletions(-) create mode 100644 crates/window/src/desktop.rs create mode 100644 crates/window/src/layout.rs create mode 100644 crates/window/src/policy.rs diff --git a/crates/window/README.md b/crates/window/README.md index e90e8730d..e9dc47fbb 100644 --- a/crates/window/README.md +++ b/crates/window/README.md @@ -8,53 +8,65 @@ window per document. ## Why -egui apps either keep the desktop's window frame (titlebar, borders, window buttons) or turn it off -and draw their own. Across the suite this had drifted: - -- some apps always drew their own frame on Linux (so KDE's titlebar was replaced by the app's), -- some always kept the desktop's, -- some probed the compositor and only drew their own on GNOME. - -So two Craft apps open side by side on KDE could wear different titlebars. This crate makes the -decision in one place, matching each desktop. +egui apps either keep the desktop's window frame (title bar, borders, window buttons) or turn it +off and draw their own. This crate makes that decision in one place, so every Craft app wears the +same title bar on a given desktop. ## The rule -| Platform | Frame | -| --- | --- | -| macOS, Windows | desktop (the OS) | -| Linux/BSD on X11 | desktop (the window manager) | -| Linux/BSD on Wayland **with** `zxdg_decoration_manager_v1` (KDE, Sway, Hyprland, COSMIC) | desktop (the compositor) | -| Linux/BSD on Wayland **without** it (GNOME's Mutter) | the app | +The app's own bar is the title bar by default (`Frame::Integrated`). The user setting "Use system +title bar" (`TitleBarPref::System`) gives the frame to the OS, window manager or compositor +(`Frame::Native`). -When the app draws the frame, the decision carries the window-button `Layout` the desktop names -(GNOME's `button-layout`), so the app's header bar puts minimize/maximize/close where that desktop -puts them. This crate decides *whether* and *where*; drawing the buttons stays in each app, whose -title bars differ. +| Platform, desktop, backend | Default | With "Use system title bar" | +| --- | --- | --- | +| macOS | `Inset`: full-size content view, hidden title, traffic lights over the app's bar | `Native` | +| Windows | `Integrated(Windows)`: minimize, maximize, close at the right | `Native` | +| GNOME, Wayland or XWayland | `Integrated(Adwaita)`, in the order of `button-layout` | `Native` | +| KDE, Wayland or X11 | `Integrated(Breeze)`, in the order of KWin's `ButtonsOnLeft` and `ButtonsOnRight` | `Native` | +| COSMIC, Xfce, Cinnamon, MATE, other floating desktops | `Integrated(Generic)` in the order of `gtk-decoration-layout`; Adwaita or Breeze when the GTK theme is one of those | `Native` | +| Tiling compositors and window managers (Sway, Hyprland, i3, niri, river, from `XDG_CURRENT_DESKTOP`) | `Native` | `Native` | +| Wayland without server-side decorations, not GNOME | `Integrated(Generic)` | `Native` | +| Web | `Native`; no frame code runs | `Native` | + +The backend is the one the app opens its window on: an app that forces XWayland on a Wayland +session passes `Backend::X11`. The Wayland registry is probed (500 ms at most) only for +`Backend::Wayland`. + +`policy::decide` is pure and takes the OS, backend, desktop, setting and override. `decide_for` +reads the platform once and calls it. ### Override -`_TITLEBAR` forces the choice. With the default prefix `CRAFT`: `CRAFT_TITLEBAR=app` forces -the app frame, `=system` (or `=desktop`) forces the desktop frame. Apps pass their own prefix -(`Prefix::new("PDFCRAFT")` → `PDFCRAFT_TITLEBAR`) so each keeps its own variable name. +`_TITLEBAR` wins over the setting. With the default prefix `CRAFT`: `CRAFT_TITLEBAR=app` +forces the app's bar (also on tiling compositors), `=system` or `=desktop` forces the native frame. +Apps pass their own prefix (`Prefix::new("PDFCRAFT")` reads `PDFCRAFT_TITLEBAR`). +When `_TITLEBAR` is unset, `CRAFT_TITLEBAR` applies, so one variable covers every app. `Decision::overridden` reports `Some(true)` or `Some(false)` when the variable decided. ## Use ```rust,ignore -use craft_window::{Choice, Prefix, decorate}; +use craft_window::Prefix; +use craft_window::policy::{Backend, Frame, TitleBarPref, apply, decide_for}; // Once at startup, before the window is created: -let choice = Choice::probe(Prefix::new("PHOTOCRAFT")); -let viewport = decorate(egui::ViewportBuilder::default().with_title("PhotoCraft"), choice.frame()); +let pref = if settings.system_title_bar { TitleBarPref::System } else { TitleBarPref::Integrated }; +// Backend::from_env() is what winit opens; an app that forces XWayland passes Backend::X11. +let decision = decide_for(Prefix::new("PHOTOCRAFT"), Backend::from_env(), pref); +let viewport = apply(egui::ViewportBuilder::default().with_title("PhotoCraft"), &decision.frame); -// If the app draws the frame, lay the header-bar buttons out by the desktop's order: -if let Some(layout) = choice.frame().layout() { - // layout.left / layout.right -> draw Minimize/Maximize/Close there +if let Frame::Integrated(captions) = &decision.frame { + // captions.family picks the button look; captions.layout.left / .right place the buttons. } ``` -An app that offers a "System title bar" preference checks `choice.overridden()` and its own setting, -and can override the probe by setting the frame to `Frame::Desktop`. +This crate decides *whether* and *where*; drawing the buttons stays in each app. + +## Older API + +`Choice::probe`, the root `Frame::{Desktop, App}` and `decorate` follow the earlier two-way rule +(the desktop draws the frame, except on Wayland without server-side decorations). They still work; +`policy` supersedes them. The `egui` feature adds [`decorate`], the [`title_bar`](#the-apps-own-title-bar) module and `Viewports`. Without it, the crate is pure decision and bookkeeping logic with no egui dependency. diff --git a/crates/window/src/desktop.rs b/crates/window/src/desktop.rs new file mode 100644 index 000000000..2ec0fc4c7 --- /dev/null +++ b/crates/window/src/desktop.rs @@ -0,0 +1,130 @@ +//! Which desktop and backend the window opens on, read from the environment and, on Wayland, the +//! compositor's registry. + +/// The windowing backend the app opens its window on. An app that forces XWayland on a Wayland +/// session passes [`Backend::X11`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backend { + Wayland, + X11, +} + +/// The kind of desktop, as far as the frame decision cares. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DesktopKind { + Gnome, + Kde, + /// A tiling compositor or window manager (Sway, Hyprland, i3, niri, river, ...). + Tiling, + /// Any other desktop: COSMIC, Xfce, Cinnamon, MATE, an unknown one. Budgie counts as GNOME. + Other, +} + +/// What the running desktop is and offers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Desktop { + pub kind: DesktopKind, + /// Whether the compositor offers server-side decorations (`zxdg_decoration_manager_v1`). + /// `None` when not probed (X11, or the probe failed). + pub ssd: Option, + /// The GTK theme name, when `GTK_THEME` or GTK's `settings.ini` names one. + pub gtk_theme: Option, + /// The icon theme: KDE's (`kdeglobals`) or GTK's setting, else `breeze` on KDE and `Adwaita` + /// elsewhere. `None` off Linux and the BSDs. + pub icon_theme: Option, +} + +impl Backend { + /// The backend winit opens by default: Wayland when `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is + /// set, else X11. An app that forces X11 (XWayland) passes [`Backend::X11`] instead. + pub fn from_env() -> Backend { + let set = |k| std::env::var_os(k).is_some_and(|v| !v.is_empty()); + if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") { Backend::Wayland } else { Backend::X11 } + } +} + +impl Desktop { + /// Read the desktop. Reads environment variables and small config files; the Wayland registry + /// is probed (500 ms at most) only when `backend` is [`Backend::Wayland`]. + pub fn detect(backend: Backend) -> Desktop { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] + { + let var = |k: &str| std::env::var(k).ok(); + let ssd = if backend == Backend::Wayland { crate::platform::wayland_offers_ssd() } else { None }; + let kind = kind_from_env(&var); + Desktop { kind, ssd, gtk_theme: crate::layout::read_gtk_theme(&var), icon_theme: Some(crate::layout::read_icon_theme(kind, &var)) } + } + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd")))] + { + let _ = backend; + Desktop { kind: DesktopKind::Other, ssd: None, gtk_theme: None, icon_theme: None } + } + } +} + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))] +/// Compositors and window managers that tile, matched against the `XDG_CURRENT_DESKTOP` entries. +const TILING: &[&str] = &["sway", "hyprland", "i3", "niri", "river", "bspwm", "awesome", "dwl", "qtile", "herbstluftwm", "leftwm", "xmonad", "dwm"]; + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))] +/// The desktop kind from the first of its variables that is set (`XDG_CURRENT_DESKTOP`, +/// `XDG_SESSION_DESKTOP`, `DESKTOP_SESSION`). `ubuntu:GNOME` and `Budgie:GNOME` count as GNOME. +pub(crate) fn kind_from_env(var: &dyn Fn(&str) -> Option) -> DesktopKind { + // A compositor's own socket variable names it even when the desktop variables are missing. + if ["SWAYSOCK", "I3SOCK", "HYPRLAND_INSTANCE_SIGNATURE", "NIRI_SOCKET"].iter().any(|k| var(k).is_some_and(|v| !v.is_empty())) { + return DesktopKind::Tiling; + } + for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] { + let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue }; + if v.split(':').map(str::trim).any(|entry| TILING.contains(&entry)) { + return DesktopKind::Tiling; + } + return if v.contains("gnome") { + DesktopKind::Gnome + } else if v.contains("kde") || v.contains("plasma") { + DesktopKind::Kde + } else { + DesktopKind::Other + }; + } + if var("KDE_FULL_SESSION").is_some() { + DesktopKind::Kde + } else if var("GNOME_DESKTOP_SESSION_ID").is_some() { + DesktopKind::Gnome + } else { + DesktopKind::Other + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone()) + } + + #[test] + fn the_desktop_comes_from_the_first_variable_set() { + assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), DesktopKind::Gnome); + assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), DesktopKind::Kde); + assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), DesktopKind::Other); + assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), DesktopKind::Kde); + assert_eq!(kind_from_env(&env(&[("KDE_FULL_SESSION", "true")])), DesktopKind::Kde); + assert_eq!(kind_from_env(&env(&[("GNOME_DESKTOP_SESSION_ID", "x")])), DesktopKind::Gnome); + assert_eq!(kind_from_env(&env(&[])), DesktopKind::Other); + } + + #[test] + fn tiling_compositors_are_recognised() { + for name in ["sway", "Hyprland", "i3", "niri", "river", "bspwm"] { + assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", name)])), DesktopKind::Tiling, "{name}"); + } + assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "i3:GNOME-Flashback")])), DesktopKind::Tiling); + assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "COSMIC")])), DesktopKind::Other); + assert_eq!(kind_from_env(&env(&[("SWAYSOCK", "/run/sway.sock")])), DesktopKind::Tiling); + assert_eq!(kind_from_env(&env(&[("NIRI_SOCKET", "/run/niri")])), DesktopKind::Tiling); + assert_eq!(kind_from_env(&env(&[("XDG_CURRENT_DESKTOP", "Budgie:GNOME")])), DesktopKind::Gnome); + } +} diff --git a/crates/window/src/layout.rs b/crates/window/src/layout.rs new file mode 100644 index 000000000..1c62a5365 --- /dev/null +++ b/crates/window/src/layout.rs @@ -0,0 +1,234 @@ +//! Where the desktop puts the window buttons, and which look they get. The parsers are pure +//! functions over strings; the file reads at the bottom are thin. + +use crate::{Layout, policy::CaptionFamily}; + +use crate::desktop::DesktopKind; + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))] +/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`). +pub(crate) fn ini_value(text: &str, section: &str, key: &str) -> Option { + let mut inside = false; + for line in text.lines().map(str::trim) { + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + inside = name == section; + } else if inside + && let Some((k, v)) = line.split_once('=') + && k.trim() == key + { + return Some(v.trim().to_string()); + } + } + None +} + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))] +/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a +/// button: `I` minimize, `A` maximize, `X` close) as a GTK layout string. Missing keys keep KWin's +/// defaults: the window menu on the left, minimize, maximize and close on the right. +pub(crate) fn kwin_layout(kwinrc: &str) -> String { + let side = |key: &str, default: &str| { + let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string()); + letters + .chars() + .filter_map(|c| match c { + 'I' => Some("minimize"), + 'A' => Some("maximize"), + 'X' => Some("close"), + _ => None, + }) + .collect::>() + .join(",") + }; + format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX")) +} + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))] +/// The `gtk-decoration-layout` and `gtk-theme-name` of a GTK 3 `settings.ini`. +pub(crate) fn gtk_settings(settings_ini: &str) -> (Option, Option) { + (ini_value(settings_ini, "Settings", "gtk-decoration-layout"), ini_value(settings_ini, "Settings", "gtk-theme-name")) +} + +/// The caption look. GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; +/// elsewhere the GTK theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the +/// plain buttons. +pub(crate) fn family(kind: DesktopKind, gtk_theme: Option<&str>) -> CaptionFamily { + match kind { + DesktopKind::Gnome => CaptionFamily::Adwaita, + DesktopKind::Kde => CaptionFamily::Breeze, + DesktopKind::Tiling | DesktopKind::Other => family_from_gtk_theme(gtk_theme), + } +} + +/// The family a GTK theme name stands for, else [`CaptionFamily::Generic`]. +pub(crate) fn family_from_gtk_theme(gtk_theme: Option<&str>) -> CaptionFamily { + let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase(); + if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) { + CaptionFamily::Adwaita + } else if theme.starts_with("breeze") { + CaptionFamily::Breeze + } else { + CaptionFamily::Generic + } +} + +/// What a desktop that names no layout gets: GNOME's default shows Close only, GTK's all three. +pub(crate) fn default_layout(kind: DesktopKind) -> Layout { + if kind == DesktopKind::Gnome { Layout::parse("appmenu:close") } else { Layout::default() } +} + +/// A parsed layout, or the default when the text names no usable button. +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))] +fn parse_or_default(text: Option<&str>, kind: DesktopKind) -> Layout { + text.map(Layout::parse).filter(Layout::is_usable).unwrap_or_else(|| default_layout(kind)) +} + +/// The layout `kind` uses, read from the desktop's files (`kwinrc` on KDE, GTK's `settings.ini` +/// elsewhere). GNOME's comes from `gsettings`, then GTK's `settings.ini`, then Close only. +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] +pub(crate) fn read_layout(kind: DesktopKind, var: &dyn Fn(&str) -> Option) -> Layout { + match kind { + DesktopKind::Gnome => { + let from_gsettings = crate::platform::gsettings_button_layout().map(|v| Layout::parse(&v)).filter(Layout::is_usable); + from_gsettings.unwrap_or_else(|| { + let ini = read_config(var, "gtk-3.0/settings.ini").unwrap_or_default(); + parse_or_default(gtk_settings(&ini).0.as_deref(), kind) + }) + } + DesktopKind::Kde => { + let rc = read_config(var, "kwinrc").unwrap_or_default(); + parse_or_default(Some(&kwin_layout(&rc)), kind) + } + DesktopKind::Tiling | DesktopKind::Other => { + let ini = read_config(var, "gtk-3.0/settings.ini").unwrap_or_default(); + parse_or_default(gtk_settings(&ini).0.as_deref(), kind) + } + } +} + +/// The GTK theme name from GTK 3's `settings.ini`. +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] +pub(crate) fn read_gtk_theme(var: &dyn Fn(&str) -> Option) -> Option { + let from_env = var("GTK_THEME").map(|t| t.split(':').next().unwrap_or_default().to_string()).filter(|t| !t.is_empty()); + from_env.or_else(|| read_config(var, "gtk-3.0/settings.ini").and_then(|ini| gtk_settings(&ini).1)) +} + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))] +/// The icon theme name for `kind`'s own setting (`kdeglobals` `[Icons] Theme` on KDE, GTK's +/// `gtk-icon-theme-name` elsewhere), else the desktop's default theme. +pub(crate) fn icon_theme(kind: DesktopKind, kdeglobals: &str, settings_ini: &str) -> String { + let found = + if kind == DesktopKind::Kde { ini_value(kdeglobals, "Icons", "Theme") } else { ini_value(settings_ini, "Settings", "gtk-icon-theme-name") }; + found.filter(|t| !t.is_empty()).unwrap_or_else(|| if kind == DesktopKind::Kde { "breeze" } else { "Adwaita" }.to_string()) +} + +/// [`icon_theme`] from the user's config files. +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] +pub(crate) fn read_icon_theme(kind: DesktopKind, var: &dyn Fn(&str) -> Option) -> String { + let kdeglobals = if kind == DesktopKind::Kde { read_config(var, "kdeglobals").unwrap_or_default() } else { String::new() }; + let ini = read_config(var, "gtk-3.0/settings.ini").unwrap_or_default(); + icon_theme(kind, &kdeglobals, &ini) +} + +/// A small file under the user's config directory (`XDG_CONFIG_HOME`, else `~/.config`). +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] +fn read_config(var: &dyn Fn(&str) -> Option, relative: &str) -> Option { + use std::io::Read; + let config = var("XDG_CONFIG_HOME") + .filter(|d| std::path::Path::new(d).is_absolute()) + .map(std::path::PathBuf::from) + .or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")))?; + let mut bytes = Vec::new(); + // Config files are small; the cap keeps a stray huge file from stalling startup. + std::fs::File::open(config.join(relative)).ok()?.take(1 << 20).read_to_end(&mut bytes).ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Button::{Close, Maximize, Minimize}; + + #[test] + fn the_family_follows_the_desktop_then_the_gtk_theme() { + assert_eq!(family(DesktopKind::Gnome, Some("Arc")), CaptionFamily::Adwaita); + assert_eq!(family(DesktopKind::Kde, None), CaptionFamily::Breeze); + assert_eq!(family(DesktopKind::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita); + assert_eq!(family(DesktopKind::Other, Some("Yaru-blue")), CaptionFamily::Adwaita); + assert_eq!(family(DesktopKind::Other, Some("Breeze-Dark")), CaptionFamily::Breeze); + assert_eq!(family(DesktopKind::Other, Some("Mint-Y")), CaptionFamily::Generic); + assert_eq!(family(DesktopKind::Other, None), CaptionFamily::Generic); + } + + #[test] + fn kwin_button_letters_become_a_gtk_layout() { + assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults"); + let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n"; + assert_eq!(kwin_layout(rc), "close,minimize,maximize:"); + assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze")); + assert_eq!(ini_value(rc, "nope", "library"), None); + // Garbage is just no buttons on that side, never a panic. + assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=\u{ff}\u{20ac}=X\n"), ":close"); + } + + #[test] + fn the_icon_theme_comes_from_the_setting_or_the_desktop_default() { + let globals = "[Icons]\nTheme=breeze-dark\n"; + let ini = "[Settings]\ngtk-icon-theme-name=Papirus\n"; + assert_eq!(icon_theme(DesktopKind::Kde, globals, ini), "breeze-dark"); + assert_eq!(icon_theme(DesktopKind::Other, globals, ini), "Papirus"); + assert_eq!(icon_theme(DesktopKind::Gnome, "", ""), "Adwaita"); + assert_eq!(icon_theme(DesktopKind::Kde, "", ini), "breeze"); + } + + #[test] + fn kwin_order_parses_into_a_layout() { + let rc = "[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\n"; + let layout = Layout::parse(&kwin_layout(rc)); + assert_eq!(layout.left, vec![Close, Minimize, Maximize]); + assert!(layout.right.is_empty()); + } + + #[test] + fn gtk_settings_give_the_layout_and_theme() { + let ini = "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n"; + assert_eq!(gtk_settings(ini), (Some("close,maximize:".into()), Some("Yaru".into()))); + assert_eq!(gtk_settings(""), (None, None)); + let layout = Layout::parse("close,maximize:"); + assert_eq!((layout.left, layout.right), (vec![Close, Maximize], vec![])); + } + + #[test] + fn missing_or_unusable_layouts_fall_back_to_the_desktops_default() { + assert_eq!(parse_or_default(None, DesktopKind::Gnome).right, vec![Close], "GNOME's default shows Close only"); + assert_eq!(parse_or_default(Some("appmenu:spacer"), DesktopKind::Other).right, vec![Minimize, Maximize, Close]); + assert_eq!(parse_or_default(Some("close:"), DesktopKind::Other).left, vec![Close]); + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] + #[test] + fn settings_files_are_read_from_the_config_dir() { + let dir = std::env::temp_dir().join(format!("craft-window-layout-{}", std::process::id())); + std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap_or_default(); + std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n") + .unwrap_or_default(); + std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap_or_default(); + let path = dir.to_string_lossy().into_owned(); + let var = move |k: &str| (k == "XDG_CONFIG_HOME").then(|| path.clone()); + assert_eq!(read_gtk_theme(&var).as_deref(), Some("Yaru")); + std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap_or_default(); + assert_eq!(read_icon_theme(DesktopKind::Kde, &var), "breeze-dark"); + let path2 = dir.to_string_lossy().into_owned(); + let with_env = move |k: &str| match k { + "XDG_CONFIG_HOME" => Some(path2.clone()), + "GTK_THEME" => Some("Breeze:dark".to_string()), + _ => None, + }; + assert_eq!(read_gtk_theme(&with_env).as_deref(), Some("Breeze"), "GTK_THEME wins over settings.ini"); + assert_eq!(read_layout(DesktopKind::Other, &var).left, vec![Close, Maximize]); + assert_eq!(read_layout(DesktopKind::Kde, &var).right, vec![Maximize, Close]); + // A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; with no HOME nothing is found. + assert_eq!(read_gtk_theme(&|k: &str| (k == "XDG_CONFIG_HOME").then(|| "rel".to_string())), None); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/window/src/lib.rs b/crates/window/src/lib.rs index cdaed6a84..94063f9bb 100644 --- a/crates/window/src/lib.rs +++ b/crates/window/src/lib.rs @@ -10,7 +10,11 @@ //! compositor — so on KDE, say, one app wore KWin's title bar while another wore its own. This //! crate makes the decision one place. //! -//! The rule, by platform: +//! This page describes the first-generation rule ([`Choice::probe`], [`Frame`], [`decorate`]), +//! which stays for apps that have not moved. The current rule is in [`policy`]: the app's own bar +//! is the title bar by default, and the user setting "Use system title bar" turns that off. +//! +//! The rule of the first-generation API, by platform: //! //! - **macOS and Windows:** the OS always draws the frame ([`Frame::Desktop`]). (An app that insets //! its content under the traffic lights, or draws its own caption buttons on Windows, still asks @@ -133,9 +137,12 @@ //! The `restore` feature (no egui) adds [`restore`]: where a window opens, from the last session's //! geometry and the connected monitors, and the `window.json` store beside `config.toml`. +mod desktop; +mod layout; mod list; mod menu; mod place; +pub mod policy; #[cfg(feature = "egui")] pub mod title_bar; #[cfg(feature = "egui")] @@ -176,7 +183,7 @@ impl Prefix { } } -/// Who draws the window frame. +/// Who draws the window frame. Superseded by [`policy::Frame`]; kept for apps that have not moved. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Frame { /// The desktop draws it: the OS on macOS and Windows, the window manager on X11, the @@ -204,7 +211,7 @@ impl Frame { } /// How the frame was decided, so an app can log it or offer a "System title bar" preference that -/// knows the current state. +/// knows the current state. Superseded by [`policy::Decision`] and [`policy::decide_for`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Choice { frame: Frame, @@ -319,10 +326,15 @@ impl Layout { Self { left, right } } + /// The layout with the two sides swapped, for right-to-left UIs. + pub fn mirrored(&self) -> Self { + Self { left: self.right.clone(), right: self.left.clone() } + } + /// Whether the layout has at least one button, so the window can be closed by mouse. A layout /// with none (a desktop that named only buttons this crate skips) falls back to the default. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", test))] - fn is_usable(&self) -> bool { + pub(crate) fn is_usable(&self) -> bool { !self.left.is_empty() || !self.right.is_empty() } } @@ -353,7 +365,7 @@ fn app_draws_frame() -> bool { } } -/// Apply a [`Frame`] to an [`egui::ViewportBuilder`]: decorations on for the desktop frame, off for +/// Apply a [`Frame`] to an [`egui::ViewportBuilder`] (superseded by [`policy::apply`]): decorations on for the desktop frame, off for /// the app frame. Call before the window is created. The app still draws its header bar and, for /// [`Frame::App`], lays its window buttons out by [`Frame::layout`]. #[cfg(feature = "egui")] @@ -385,6 +397,12 @@ mod platform { bounded(registry_leaves_frame_to_app).flatten().unwrap_or(false) } + /// Whether the Wayland compositor offers server-side decorations: `None` when there is no + /// answer (no compositor, an inherited socket, a stalled registry), same deadline as above. + pub(super) fn wayland_offers_ssd() -> Option { + bounded(registry_leaves_frame_to_app).flatten().map(|leaves| !leaves) + } + /// Run `f` on a short-lived worker thread with a 500 ms deadline, so a stalled dependency can't /// block window startup. `None` when the thread can't be spawned or `f` doesn't finish in time; /// otherwise `f`'s return value. For a child process, prefer [`gsettings_button_layout`], which @@ -526,6 +544,12 @@ mod tests { assert!(layout.right.is_empty()); } + #[test] + fn mirrored_swaps_the_sides() { + let layout = Layout::parse("close:minimize,maximize").mirrored(); + assert_eq!((layout.left, layout.right), (vec![Button::Minimize, Button::Maximize], vec![Button::Close])); + } + #[test] fn a_layout_naming_no_known_button_is_not_usable() { assert!(!Layout::parse("appmenu:spacer").is_usable()); diff --git a/crates/window/src/policy.rs b/crates/window/src/policy.rs new file mode 100644 index 000000000..8e3544841 --- /dev/null +++ b/crates/window/src/policy.rs @@ -0,0 +1,345 @@ +//! The frame policy: who draws the title bar, decided from the OS, desktop, backend, the user's +//! "Use system title bar" setting and the `_TITLEBAR` override. +//! +//! The app's own bar is the title bar by default ([`Frame::Integrated`]); the setting turns that +//! off ([`Frame::Native`]). +//! +//! | Platform, desktop, backend | Default | With "Use system title bar" | +//! |---|---|---| +//! | macOS | `Inset` | `Native` | +//! | Windows | `Integrated(Windows)` | `Native` | +//! | GNOME, Wayland or XWayland | `Integrated(Adwaita)`, `button-layout` order | `Native` | +//! | KDE, Wayland or X11 | `Integrated(Breeze)`, KWin's order | `Native` | +//! | COSMIC, Xfce, Cinnamon, MATE, other floating desktops | `Integrated(Generic)`, or Adwaita or Breeze when the GTK theme is one of those | `Native` | +//! | Tiling compositors and window managers | `Native` | `Native` | +//! | Wayland without server-side decorations, not GNOME | `Integrated(Generic)` | `Native` | +//! | Web | `Native` (no frame code runs) | `Native` | +//! +//! ```ignore +//! let d = craft_window::policy::decide_for(Prefix::default(), Backend::Wayland, TitleBarPref::Integrated); +//! let viewport = craft_window::policy::apply(egui::ViewportBuilder::default(), &d.frame); +//! ``` + +pub use crate::desktop::{Backend, Desktop, DesktopKind}; +use crate::{Layout, Prefix, layout}; + +/// Whether KDE, COSMIC, Xfce, Cinnamon, MATE and X11 window managers, which draw their own frame, +/// get the app's bar by default. Set to `false` for [`Frame::Native`] on those desktops instead; +/// nothing else changes. +const INTEGRATED_ON_SSD_DESKTOPS: bool = true; + +/// The user setting "Use system title bar". +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TitleBarPref { + /// The app's bar is the title bar. + #[default] + Integrated, + /// The OS, window manager or compositor draws it. + System, +} + +/// The operating system family. Linux stands for Linux and the BSDs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Os { + Mac, + Windows, + Linux, + Web, +} + +impl Os { + /// The OS this build runs on. + pub const fn current() -> Os { + if cfg!(target_os = "macos") { + Os::Mac + } else if cfg!(target_os = "windows") { + Os::Windows + } else if cfg!(target_arch = "wasm32") { + Os::Web + } else { + Os::Linux + } + } +} + +/// The look of the caption buttons. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaptionFamily { + Windows, + Adwaita, + Breeze, + Generic, +} + +impl CaptionFamily { + /// A family by name (`windows`, `adwaita`, `breeze`, `generic`), case-insensitive. + pub fn parse(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "windows" => Some(Self::Windows), + "adwaita" => Some(Self::Adwaita), + "breeze" => Some(Self::Breeze), + "generic" => Some(Self::Generic), + _ => None, + } + } +} + +/// Caption buttons the app draws: their look and which end of the bar they sit at. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Captions { + pub family: CaptionFamily, + pub layout: Layout, +} + +/// Where macOS puts the traffic lights over the app's bar, in points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MacInset { + /// Width reserved at the leading edge for the lights. + pub leading: f32, + /// Distance from the window's top to the lights' vertical centre. + pub centre_from_top: f32, +} + +impl Default for MacInset { + /// The standard lights' geometry. + fn default() -> Self { + Self { leading: 78.0, centre_from_top: 14.0 } + } +} + +/// Who draws the title bar. +#[derive(Clone, Debug, PartialEq)] +pub enum Frame { + /// The OS, window manager or compositor draws it; the app's strip sits below it. + Native, + /// macOS: full-size content view and hidden title; the traffic lights sit over the app's bar. + Inset(MacInset), + /// Decorations are off; the app's bar is the title bar, with these caption buttons. + Integrated(Captions), +} + +/// The decided frame and how it was decided. +#[derive(Clone, Debug, PartialEq)] +pub struct Decision { + pub frame: Frame, + /// `Some(true)` when `_TITLEBAR` forced the app's bar, `Some(false)` when it forced the + /// system's, `None` when the platform and setting decided. + pub overridden: Option, + /// The desktop the decision was made for, so an app can hide the "Use system title bar" row + /// where it changes nothing (tiling compositors, web) without probing again. + pub desktop: Desktop, +} + +/// The decision, pure. `env` is the value of `_TITLEBAR` (`app`, `system` or `desktop`); +/// it wins over `pref`. Caption layouts are each family's default; [`decide_for`] fills in the +/// desktop's own. +pub fn decide(os: Os, backend: Backend, desktop: &Desktop, pref: TitleBarPref, env: Option<&str>) -> Decision { + decide_with(os, backend, desktop, pref, env, &layout::default_layout) +} + +/// `layout` supplies the caption layout for a desktop kind; it is called only when the frame comes +/// out integrated. +fn decide_with( + os: Os, + backend: Backend, + desktop: &Desktop, + pref: TitleBarPref, + env: Option<&str>, + layout: &dyn Fn(DesktopKind) -> Layout, +) -> Decision { + let overridden = crate::parse_override(env); + let integrated = match (overridden, pref) { + (Some(app), _) => app, + (None, pref) => pref == TitleBarPref::Integrated, + }; + let frame = + if integrated { integrated_frame(os, backend, desktop, overridden.is_some(), INTEGRATED_ON_SSD_DESKTOPS, layout) } else { Frame::Native }; + Decision { frame, overridden, desktop: desktop.clone() } +} + +/// The frame when the app's bar is wanted. `forced` is set when the override asked for it, which +/// also applies on tiling compositors. +/// `integrated_on_ssd` says whether desktops that draw their own frame get the app's bar. +fn integrated_frame( + os: Os, + backend: Backend, + desktop: &Desktop, + forced: bool, + integrated_on_ssd: bool, + layout: &dyn Fn(DesktopKind) -> Layout, +) -> Frame { + let captions = |family: CaptionFamily| { + let kind = if family == CaptionFamily::Windows { DesktopKind::Other } else { desktop.kind }; + Frame::Integrated(Captions { family, layout: layout(kind) }) + }; + match os { + Os::Web => Frame::Native, + Os::Mac => Frame::Inset(MacInset::default()), + Os::Windows => captions(CaptionFamily::Windows), + Os::Linux => { + let no_ssd = backend == Backend::Wayland && desktop.ssd == Some(false); + match desktop.kind { + DesktopKind::Tiling if !forced => Frame::Native, + DesktopKind::Tiling => captions(layout::family_from_gtk_theme(desktop.gtk_theme.as_deref())), + DesktopKind::Gnome => captions(CaptionFamily::Adwaita), + // Open decision 1: the line below is the only place that picks the app's bar on + // desktops that draw their own frame. `false` gives `Native` there. + _ if !no_ssd && !integrated_on_ssd && !forced => Frame::Native, + DesktopKind::Kde => captions(CaptionFamily::Breeze), + DesktopKind::Other if no_ssd => captions(CaptionFamily::Generic), + DesktopKind::Other => captions(layout::family(DesktopKind::Other, desktop.gtk_theme.as_deref())), + } + } + } +} + +/// Read the platform once (500 ms at most, as for [`crate::Choice::probe`]) and decide. +/// `prefix` names the override variable `_TITLEBAR`; when it is unset, `CRAFT_TITLEBAR` +/// applies. `backend` is the one the app opens its window on, [`Backend::from_env`] +/// unless the app forces X11. +pub fn decide_for(prefix: Prefix, backend: Backend, pref: TitleBarPref) -> Decision { + let os = Os::current(); + let env = std::env::var(prefix.var_name()).or_else(|_| std::env::var(Prefix::default().var_name())).ok(); + let desktop = + if os == Os::Linux { Desktop::detect(backend) } else { Desktop { kind: DesktopKind::Other, ssd: None, gtk_theme: None, icon_theme: None } }; + decide_with(os, backend, &desktop, pref, env.as_deref(), &|kind| desktop_layout(os, kind)) +} + +/// The desktop's window-button layout, where it names one. +fn desktop_layout(os: Os, kind: DesktopKind) -> Layout { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] + if os == Os::Linux { + return layout::read_layout(kind, &|k: &str| std::env::var(k).ok()); + } + let _ = os; + layout::default_layout(kind) +} + +/// Apply a [`Frame`] to an [`egui::ViewportBuilder`] before the window is created: decorations off +/// for [`Frame::Integrated`], a full-size content view with hidden title and transparent title bar +/// for [`Frame::Inset`], decorations on for [`Frame::Native`]. +#[cfg(feature = "egui")] +pub fn apply(builder: egui::ViewportBuilder, frame: &Frame) -> egui::ViewportBuilder { + match frame { + Frame::Native => builder.with_decorations(true), + Frame::Inset(_) => builder.with_decorations(true).with_fullsize_content_view(true).with_title_shown(false).with_titlebar_shown(false), + Frame::Integrated(_) => builder.with_decorations(false), + } +} + +impl From<&crate::Frame> for Frame { + /// The old two-way frame as a new one: `Desktop` is `Native`, `App(layout)` is integrated + /// with generic captions. + fn from(old: &crate::Frame) -> Self { + match old { + crate::Frame::Desktop => Frame::Native, + crate::Frame::App(layout) => Frame::Integrated(Captions { family: CaptionFamily::Generic, layout: layout.clone() }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use CaptionFamily::{Adwaita, Breeze, Generic, Windows}; + use TitleBarPref::{Integrated as Int, System}; + + fn desk(kind: DesktopKind, ssd: Option, theme: Option<&str>) -> Desktop { + Desktop { kind, ssd, gtk_theme: theme.map(str::to_string), icon_theme: None } + } + + fn family_of(d: &Decision) -> Option { + match &d.frame { + Frame::Integrated(c) => Some(c.family), + _ => None, + } + } + + #[test] + fn every_row_of_the_table() { + use DesktopKind::{Gnome, Kde, Other, Tiling}; + // (os, backend, desktop, expected family; None = Native) + let rows: Vec<(Os, Backend, Desktop, Option)> = vec![ + (Os::Windows, Backend::Wayland, desk(Other, None, None), Some(Windows)), + (Os::Linux, Backend::Wayland, desk(Gnome, Some(false), None), Some(Adwaita)), + (Os::Linux, Backend::X11, desk(Gnome, None, Some("Arc")), Some(Adwaita)), + (Os::Linux, Backend::Wayland, desk(Kde, Some(true), None), Some(Breeze)), + (Os::Linux, Backend::X11, desk(Kde, None, None), Some(Breeze)), + (Os::Linux, Backend::Wayland, desk(Other, Some(true), Some("Adwaita")), Some(Adwaita)), + (Os::Linux, Backend::Wayland, desk(Other, Some(true), Some("Breeze-Dark")), Some(Breeze)), + (Os::Linux, Backend::X11, desk(Other, None, Some("Mint-Y")), Some(Generic)), + (Os::Linux, Backend::Wayland, desk(Other, Some(true), None), Some(Generic)), + (Os::Linux, Backend::Wayland, desk(Tiling, Some(true), None), None), + (Os::Linux, Backend::X11, desk(Tiling, None, None), None), + (Os::Linux, Backend::Wayland, desk(Other, Some(false), Some("Adwaita")), Some(Generic)), + (Os::Web, Backend::X11, desk(Other, None, None), None), + ]; + for (os, backend, d, want) in rows { + let got = decide(os, backend, &d, Int, None); + assert_eq!(family_of(&got), want, "{os:?} {backend:?} {d:?}"); + assert_eq!(got.overridden, None); + if want.is_none() { + assert_eq!(got.frame, Frame::Native); + } + let system = decide(os, backend, &d, System, None); + assert_eq!(system.frame, Frame::Native, "system bar: {os:?} {d:?}"); + } + } + + #[test] + fn native_on_ssd_desktops_when_the_flag_is_off() { + use DesktopKind::{Gnome, Kde, Other}; + let frame = |kind, backend, ssd, forced| integrated_frame(Os::Linux, backend, &desk(kind, ssd, None), forced, false, &layout::default_layout); + assert_eq!(frame(Kde, Backend::Wayland, Some(true), false), Frame::Native); + assert_eq!(frame(Other, Backend::X11, None, false), Frame::Native); + assert!(matches!(frame(Kde, Backend::Wayland, Some(true), true), Frame::Integrated(_)), "the override still forces the bar"); + assert!(matches!(frame(Gnome, Backend::Wayland, Some(false), false), Frame::Integrated(_))); + assert!(matches!(frame(Other, Backend::Wayland, Some(false), false), Frame::Integrated(_)), "no SSD: nothing else draws a frame"); + } + + #[test] + fn the_decision_carries_the_desktop() { + let d = desk(DesktopKind::Tiling, Some(true), None); + assert_eq!(decide(Os::Linux, Backend::Wayland, &d, Int, None).desktop, d); + } + + #[test] + fn macos_gets_the_inset() { + let d = decide(Os::Mac, Backend::X11, &desk(DesktopKind::Other, None, None), Int, None); + assert_eq!(d.frame, Frame::Inset(MacInset::default())); + assert_eq!(decide(Os::Mac, Backend::X11, &desk(DesktopKind::Other, None, None), System, None).frame, Frame::Native); + } + + #[test] + fn the_override_wins_over_the_setting_both_ways() { + let kde = desk(DesktopKind::Kde, Some(true), None); + let system = decide(Os::Linux, Backend::Wayland, &kde, Int, Some("system")); + assert_eq!((system.frame, system.overridden), (Frame::Native, Some(false))); + let desktop = decide(Os::Linux, Backend::Wayland, &kde, Int, Some("desktop")); + assert_eq!((desktop.frame, desktop.overridden), (Frame::Native, Some(false))); + let app = decide(Os::Linux, Backend::Wayland, &kde, System, Some("app")); + assert_eq!(app.overridden, Some(true)); + assert_eq!(family_of(&app), Some(Breeze)); + // `app` also forces the bar on a tiling compositor, which would otherwise be native. + let tiling = desk(DesktopKind::Tiling, Some(true), None); + assert_eq!(family_of(&decide(Os::Linux, Backend::Wayland, &tiling, Int, Some("app"))), Some(Generic)); + // An unknown value is no override. + let unknown = decide(Os::Linux, Backend::Wayland, &kde, System, Some("maybe")); + assert_eq!((unknown.frame, unknown.overridden), (Frame::Native, None)); + } + + #[test] + fn default_layouts_follow_the_family() { + let gnome = decide(Os::Linux, Backend::Wayland, &desk(DesktopKind::Gnome, Some(false), None), Int, None); + assert!(matches!(gnome.frame, Frame::Integrated(c) if c.layout.right == vec![crate::Button::Close])); + let win = decide(Os::Windows, Backend::X11, &desk(DesktopKind::Other, None, None), Int, None); + assert!(matches!(win.frame, Frame::Integrated(c) if c.layout == Layout::default())); + } + + #[test] + fn the_old_frame_converts() { + assert_eq!(Frame::from(&crate::Frame::Desktop), Frame::Native); + assert!(matches!(Frame::from(&crate::Frame::App(Layout::default())), Frame::Integrated(_))); + assert_eq!(CaptionFamily::parse(" Breeze "), Some(Breeze)); + } +}