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
@@ -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.new file mode 100644@@ -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);+ }+}new file mode 100644@@ -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);+ }+}@@ -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());new file mode 100644@@ -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));+ }+}