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 <aiongtaigi@gmail.com>
This commit is contained in:
aionggandaiongg authored and GitHub committed 2026-11-12 10:37:27 -05:00
1 parent bcf1e69cf4
commit e56b2f1220
5 files changed
+780 -35

No files matched your search

+42 -30
View File
@@ -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
`<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.
+130
View File
@@ -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);
}
}
+234
View File
@@ -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);
}
}
+29 -5
View File
@@ -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<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
@@ -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());
+345
View File
@@ -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));
}
}