mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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:
1 parent
bcf1e69cf4
commit
e56b2f1220
5 files changed
+780
-35
No files matched your search
+42
-30
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user