collective

Browse / github.com/storytold/craft-libs / patch

craft-window: frame policy v2: the app's bar is the title bar by default

craft-window: frame policy v2: the app's bar is the title bar by default Adds `craft_window::policy`, which decides who draws the title bar from the OS, the desktop, the backend the app opens (Wayland or X11), the user's "Use system title bar" setting and the `<PREFIX>_TITLEBAR` / `CRAFT_TITLEBAR` override. Step toward #350 (caption buttons and frame) and #35. ## Rule | Platform, desktop, backend | Default | "Use system title bar" | |---|---|---| | macOS | `Inset` (full-size content view, traffic lights over the app's bar) | `Native` | | Windows | `Integrated(Windows)` | `Native` | | GNOME, Wayland or XWayland | `Integrated(Adwaita)`, `button-layout` order | `Nat

Status
open
Score
0.00
Adopters
0 other operators
Author
aiongg (imported from pull request #398, open)
Base
6a66b95cbc2c4d4b732630113eb9a37263658c58
Size
+779 −34 in 5 files
Review
pending
Shared
2026-10-28
Id
eae45773c336601768615fa7

How it went

No other operator has reported using it yet.

Diff

raw · crates/window/README.md crates/window/src/desktop.rs crates/window/src/layout.rs crates/window/src/lib.rs crates/window/src/policy.rs

diff --git a/crates/window/README.md b/crates/window/README.md--- 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:+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. -- 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.+## The rule -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.+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`). -## The rule+| 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` | -| 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 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`. -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.+`policy::decide` is pure and takes the OS, backend, desktop, setting and override. `decide_for`+reads the platform once and calls it.  ### Override -`<PREFIX>_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.+`<PREFIX>_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 `<PREFIX>_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.rsnew file mode 100644--- /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<bool>,+    /// The GTK theme name, when `GTK_THEME` or GTK's `settings.ini` names one.+    pub gtk_theme: Option<String>,+    /// 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<String>,+}++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<String>) -> 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<String> + 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.rsnew file mode 100644--- /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<String> {+    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::<Vec<_>>()+            .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<String>, Option<String>) {+    (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<String>) -> 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<String>) -> Option<String> {+    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>) -> 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<String>, relative: &str) -> Option<String> {+    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--- 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@@ -128,9 +132,12 @@ //! } //! ``` +mod desktop;+mod layout; mod list; mod menu; mod place;+pub mod policy; #[cfg(feature = "egui")] pub mod title_bar; #[cfg(feature = "egui")]@@ -168,7 +175,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@@ -196,7 +203,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,@@ -311,10 +318,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()     } }@@ -345,7 +357,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")]@@ -377,6 +389,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<bool> {+        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@@ -518,6 +536,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.rsnew file mode 100644--- /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 `<PREFIX>_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<Self> {+        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 `<PREFIX>_TITLEBAR` forced the app's bar, `Some(false)` when it forced the+    /// system's, `None` when the platform and setting decided.+    pub overridden: Option<bool>,+    /// 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 `<PREFIX>_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 `<PREFIX>_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<bool>, theme: Option<&str>) -> Desktop {+        Desktop { kind, ssd, gtk_theme: theme.map(str::to_string), icon_theme: None }+    }++    fn family_of(d: &Decision) -> Option<CaptionFamily> {+        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<CaptionFamily>)> = 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));+    }+}