Files
craft-libs/crates/window/src/lib.rs
T
Dylan M. Taylor 1f805378d4 feat(window): shared craft-window crate for consistent window decorations (#217)
* feat(window): shared crate deciding who draws the window frame

Add craft-window, which decides whether the desktop or the app draws the
window frame (titlebar, borders, window buttons) and applies it to an
egui ViewportBuilder. This fixes the drift where some Craft apps drew
their own titlebar on KDE while others kept KWin's.

The rule: macOS, Windows and X11 keep the desktop frame; a Wayland
compositor with zxdg_decoration_manager_v1 (KDE, Sway, Hyprland, COSMIC)
keeps its frame; one without it (GNOME) leaves the frame to the app.
<PREFIX>_TITLEBAR=app|system|desktop overrides. When the app draws the
frame, the decision carries the window-button Layout from GNOME's
button-layout so the app's header bar matches the desktop's order.

Decision logic only; drawing the buttons stays in each app. Extracted and
generalized from pdfcraft's decorations.rs. forbid(unsafe_code); default
build has no egui dependency (egui feature adds decorate()).

* style(window): rustfmt the Wayland probe and Dispatch impl

cargo fmt --check failed CI: collapse the thread-spawn builder chain and
the Dispatch::event signature onto the lines rustfmt wants (verified with
rustfmt 1.98 against the workspace rustfmt.toml).

* fix(window): address review — WAYLAND_SOCKET, bounded gsettings, checkout creds

- Skip the Wayland probe when WAYLAND_SOCKET is set: connect_to_env would
  take ownership of the inherited fd and unset the variable, closing the
  descriptor winit needs. Keep the desktop frame instead.
- Run the gsettings button-layout lookup behind the same 500 ms bounded
  worker as the Wayland probe, so a stalled gsettings can't block startup.
  Factored the worker into a shared bounded() helper.
- window.yml: set persist-credentials: false on checkout so the token
  isn't left in .git/config while cargo runs dependency build scripts.
- README: document the socket guard and the bounded gsettings lookup.

Verified with rustfmt 1.98 and clippy -D warnings (both feature sets).

* fix(window): reap the gsettings child on timeout; document probe tradeoffs

Follow-up to review: the button-layout lookup now spawns gsettings with
piped output, reads it on a worker, and kills+reaps the child whether it
finishes or times out — so a stalled gsettings leaves neither a blocked
process nor a parked worker (it is a one-shot startup call).

Also document, on the Wayland probe, that an inherited WAYLAND_SOCKET
(rare; normal launches get WAYLAND_DISPLAY) deliberately falls back to the
desktop frame to protect winit's connection — the same frame an app that
doesn't probe would get, a safe default rather than a regression.

Verified: rustfmt 1.98 clean, clippy -D warnings clean (default + egui),
tests pass.
2026-10-21 05:35:20 -04:00

440 lines
19 KiB
Rust

#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
//! Decide who draws the window frame, so every Craft app shows the same titlebar and borders on a
//! given desktop.
//!
//! egui apps either let the desktop draw the window frame (the title bar, borders and window
//! buttons) or turn it off and draw their own. Across the suite this decision had drifted: some
//! apps always drew their own frame on Linux, some always kept the desktop's, and some probed the
//! 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:
//!
//! - **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
//! the OS for a decorated window and overdraws it; that is the app's choice and not this crate's.)
//! - **Linux and the BSDs, X11:** the window manager draws the frame ([`Frame::Desktop`]).
//! - **Linux and the BSDs, Wayland:** a compositor that offers server-side decorations
//! (`zxdg_decoration_manager_v1` — KDE, Sway, Hyprland, COSMIC) draws the frame
//! ([`Frame::Desktop`]). One that does not (GNOME's Mutter) leaves the frame to the app
//! ([`Frame::App`]): without this, winit draws an imitation frame that matches no desktop.
//!
//! An environment variable overrides the choice, for users whose compositor the probe can't read
//! or who simply prefer the other frame. With the default prefix `CRAFT`, `CRAFT_TITLEBAR=app`
//! forces [`Frame::App`] and `=system` (or `=desktop`) forces [`Frame::Desktop`]; apps may pass
//! their own prefix (`PDFCRAFT`, `FIGCRAFT`, …) so each keeps its own variable name.
//!
//! When the app draws the frame, [`Frame::App`] carries the [`Layout`] of window buttons the
//! desktop names (GNOME's `button-layout`), so the app's header bar puts minimize, maximize and
//! close where that desktop puts them. This crate decides *whether* and *where*; drawing the
//! buttons stays with each app (their title bars differ).
//!
//! # With egui
//!
//! The `egui` feature adds [`decorate`], which applies the decision to an
//! [`egui::ViewportBuilder`] before the window is created:
//!
//! ```ignore
//! let viewport = egui::ViewportBuilder::default().with_title("PhotoCraft");
//! // The app may still prefer its own frame when the user asked for it in preferences:
//! let choice = craft_window::Choice::probe(craft_window::Prefix::default());
//! let viewport = craft_window::decorate(viewport, choice.frame());
//! ```
//!
//! The probe opens a short-lived Wayland connection and reads the global registry once; it never
//! touches the connection winit inherits. Call it once at startup.
/// The environment-variable prefix an app overrides the frame with: `<PREFIX>_TITLEBAR`.
///
/// `Prefix::default()` is `CRAFT`, so `CRAFT_TITLEBAR`. An app that wants its own name passes
/// [`Prefix::new`] (`Prefix::new("PDFCRAFT")` reads `PDFCRAFT_TITLEBAR`). The name is used as
/// given; it should be ASCII letters, digits and underscores, as environment names are.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Prefix(&'static str);
impl Default for Prefix {
fn default() -> Self {
Self("CRAFT")
}
}
impl Prefix {
/// An app-specific prefix, so the override variable is `<prefix>_TITLEBAR`.
pub const fn new(prefix: &'static str) -> Self {
Self(prefix)
}
/// The environment variable this prefix reads, e.g. `CRAFT_TITLEBAR`.
pub fn var_name(self) -> String {
format!("{}_TITLEBAR", self.0)
}
}
/// Who draws the window frame.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Frame {
/// The desktop draws it: the OS on macOS and Windows, the window manager on X11, the
/// compositor on a Wayland session with server-side decorations. The app leaves decorations on.
Desktop,
/// The app draws it (GNOME's Wayland session, or an override). The app turns decorations off
/// and draws a header bar with the window buttons in this [`Layout`].
App(Layout),
}
impl Frame {
/// Whether the app should keep the OS/compositor decorations on (`true` for [`Frame::Desktop`]).
/// This is the value to pass to `egui::ViewportBuilder::with_decorations`.
pub fn decorations(&self) -> bool {
matches!(self, Frame::Desktop)
}
/// The window-button layout when the app draws the frame, else `None`.
pub fn layout(&self) -> Option<&Layout> {
match self {
Frame::App(layout) => Some(layout),
Frame::Desktop => None,
}
}
}
/// How the frame was decided, so an app can log it or offer a "System title bar" preference that
/// knows the current state.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Choice {
frame: Frame,
/// `Some(true)` when `<PREFIX>_TITLEBAR` forced the app frame, `Some(false)` when it forced the
/// desktop frame, `None` when the platform decided.
overridden: Option<bool>,
}
impl Choice {
/// Probe the running platform and desktop and decide who draws the frame.
///
/// `prefix` names the override variable (`<prefix>_TITLEBAR`). On macOS, Windows and X11 this
/// does no I/O; on a Wayland session it opens a short-lived connection to read the registry.
pub fn probe(prefix: Prefix) -> Self {
match override_frame(&prefix.var_name()) {
Some(true) => Self { frame: Frame::App(Layout::from_desktop()), overridden: Some(true) },
Some(false) => Self { frame: Frame::Desktop, overridden: Some(false) },
None if app_draws_frame() => Self { frame: Frame::App(Layout::from_desktop()), overridden: None },
None => Self { frame: Frame::Desktop, overridden: None },
}
}
/// The decided frame.
pub fn frame(&self) -> &Frame {
&self.frame
}
/// The decided frame, consumed.
pub fn into_frame(self) -> Frame {
self.frame
}
/// Whether `<PREFIX>_TITLEBAR` set the frame: `Some(true)` it forced the app's, `Some(false)`
/// the desktop's, `None` the platform decided.
pub fn overridden(&self) -> Option<bool> {
self.overridden
}
}
/// Read `<var>`: `app` → force the app frame, `system`/`desktop` → force the desktop frame,
/// anything else (or unset) → let the platform decide.
fn override_frame(var: &str) -> Option<bool> {
parse_override(std::env::var(var).ok().as_deref())
}
/// The override a `<PREFIX>_TITLEBAR` value asks for: `Some(true)` the app frame, `Some(false)` the
/// desktop frame, `None` to let the platform decide. Pure, so it is tested without the environment.
fn parse_override(value: Option<&str>) -> Option<bool> {
match value.map(str::trim) {
Some("app") => Some(true),
Some("system" | "desktop") => Some(false),
_ => None,
}
}
/// A window button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Button {
Minimize,
Maximize,
Close,
}
/// Which window buttons go on which end of the app-drawn header bar, outermost last.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
pub left: Vec<Button>,
pub right: Vec<Button>,
}
impl Default for Layout {
/// What a desktop that names no layout gets: all three, on the right.
fn default() -> Self {
Self { left: Vec::new(), right: vec![Button::Minimize, Button::Maximize, Button::Close] }
}
}
impl Layout {
/// The desktop's window-button layout, read from GNOME's `button-layout` where it is set, else
/// the default (all three on the right). Only meaningful when the app draws the frame.
pub fn from_desktop() -> Self {
desktop_button_layout()
}
/// Parse GNOME's `org.gnome.desktop.wm.preferences button-layout`: `left:right`, each a comma
/// list such as `appmenu:minimize,maximize,close` (Fedora's default is `appmenu:close`).
/// Without a colon everything is on the left, as in GTK. Names this crate has no button for
/// (`appmenu`, `icon`, `menu`, `spacer`) are skipped, and a button named twice appears once.
/// `gsettings` prints the value quoted; the quotes are dropped.
pub fn parse(value: &str) -> Self {
let value = value.trim().trim_matches(|c| c == '\'' || c == '"');
let (left, right) = value.split_once(':').unwrap_or((value, ""));
let mut seen: Vec<Button> = Vec::new();
let mut side = |list: &str| {
let mut out = Vec::new();
for name in list.split(',') {
let button = match name.trim() {
"minimize" => Button::Minimize,
"maximize" => Button::Maximize,
"close" => Button::Close,
_ => continue,
};
if !seen.contains(&button) {
seen.push(button);
out.push(button);
}
}
out
};
let left = side(left);
let right = side(right);
Self { left, right }
}
/// 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 {
!self.left.is_empty() || !self.right.is_empty()
}
}
/// GNOME's button layout where `gsettings` can read it, else the default. Returns the default on
/// any platform without `gsettings` or when the value names no usable button.
fn desktop_button_layout() -> Layout {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
if let Some(value) = platform::gsettings_button_layout() {
let layout = Layout::parse(&value);
if layout.is_usable() {
return layout;
}
}
Layout::default()
}
/// Whether the app should draw the frame itself: a Wayland compositor with no server-side
/// decorations. `false` on macOS, Windows, X11 and any Wayland compositor that decorates.
fn app_draws_frame() -> bool {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
{
platform::wayland_leaves_frame_to_app()
}
#[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd")))]
{
false
}
}
/// Apply a [`Frame`] to an [`egui::ViewportBuilder`]: 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")]
pub fn decorate(builder: egui::ViewportBuilder, frame: &Frame) -> egui::ViewportBuilder {
builder.with_decorations(frame.decorations())
}
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
mod platform {
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, QueueHandle};
/// Whether this is a Wayland session whose compositor offers no server-side decorations
/// (`zxdg_decoration_manager_v1`), so the frame is left to the app (GNOME). `false` on X11
/// (no Wayland connection) and on any compositor that offers them (KDE, Sway, Hyprland,
/// COSMIC).
///
/// Called once at startup, before the window is created. It runs the registry read on a
/// worker thread with a 500 ms deadline so a stalled compositor can't block startup; on
/// failure or timeout it returns `false` and the window keeps the desktop's frame. (A
/// compositor that never answers would leave that one worker parked for the process's life;
/// acceptable because this is a single, one-shot call, not a repeated one.)
pub(super) fn wayland_leaves_frame_to_app() -> bool {
bounded(registry_leaves_frame_to_app).flatten().unwrap_or(false)
}
/// 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
/// also kills and reaps the child on timeout; this helper only bounds the caller's wait.
fn bounded<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
let (send, receive) = mpsc::sync_channel(1);
let spawned = std::thread::Builder::new().name("craft-window-probe".into()).spawn(move || {
let _ = send.send(f());
});
if spawned.is_err() {
return None;
}
receive.recv_timeout(Duration::from_millis(500)).ok()
}
/// A fresh connection (never winit's inherited one): `None` when there is no Wayland
/// compositor, else whether it offers no decoration manager.
fn registry_leaves_frame_to_app() -> Option<bool> {
// `connect_to_env` would take ownership of an inherited `WAYLAND_SOCKET` fd and unset the
// variable; dropping this short-lived connection would then close the descriptor winit
// needs. So when the compositor handed us an open socket (rare: normal launches get
// `WAYLAND_DISPLAY`, a name we could reopen, not `WAYLAND_SOCKET`), skip the probe and keep
// the desktop frame. On GNOME this means winit's fallback frame rather than an app-drawn
// one, the same as an app that doesn't probe at all \u2014 a safe default, not a regression,
// taken only to protect winit's connection.
if std::env::var_os("WAYLAND_SOCKET").is_some() {
return None;
}
let conn = Connection::connect_to_env().ok()?;
let (globals, _queue) = registry_queue_init::<Registry>(&conn).ok()?;
let offered = globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"));
Some(!offered)
}
/// GNOME's `button-layout`, read with `gsettings`. `None` when `gsettings` is missing, fails,
/// times out, or prints nothing usable. The child is spawned with piped output and read on a
/// worker thread; if it doesn't finish within 500 ms the child is killed and reaped, so a
/// stalled `gsettings` leaves neither a blocked process nor a parked worker.
pub(super) fn gsettings_button_layout() -> Option<String> {
let mut child = Command::new("gsettings")
.args(["get", "org.gnome.desktop.wm.preferences", "button-layout"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let stdout = child.stdout.take()?;
let (send, receive) = mpsc::sync_channel(1);
let reader = std::thread::Builder::new().name("craft-window-gsettings".into()).spawn(move || {
let mut stdout = stdout;
let mut buf = String::new();
let read = std::io::Read::read_to_string(&mut stdout, &mut buf);
let _ = send.send(read.ok().map(|_| buf));
});
if reader.is_err() {
let _ = child.kill();
let _ = child.wait();
return None;
}
let output = receive.recv_timeout(Duration::from_millis(500));
// Kill-and-reap whether it finished or timed out: a finished child still needs reaping, and
// a timed-out one must not linger. `kill` on an exited child is a harmless error.
let _ = child.kill();
let _ = child.wait();
let value = output.ok().flatten()?;
let value = value.trim();
if value.is_empty() { None } else { Some(value.to_string()) }
}
/// The registry roundtrip needs a `Dispatch`, but no event carries state here.
struct Registry;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(_: &mut Self, _: &wl_registry::WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
}
}
#[cfg(test)]
mod tests {
use super::{Button, Frame, Layout, Prefix, parse_override};
#[test]
fn prefix_names_the_override_variable() {
assert_eq!(Prefix::default().var_name(), "CRAFT_TITLEBAR");
assert_eq!(Prefix::new("PDFCRAFT").var_name(), "PDFCRAFT_TITLEBAR");
}
#[test]
fn the_override_reads_app_and_system() {
assert_eq!(parse_override(Some("app")), Some(true));
assert_eq!(parse_override(Some(" system ")), Some(false));
assert_eq!(parse_override(Some("desktop")), Some(false));
assert_eq!(parse_override(Some("nonsense")), None);
assert_eq!(parse_override(None), None);
}
#[test]
fn frame_maps_to_decorations_and_layout() {
assert!(Frame::Desktop.decorations());
assert!(Frame::Desktop.layout().is_none());
let app = Frame::App(Layout::default());
assert!(!app.decorations());
assert!(app.layout().is_some());
}
#[test]
fn the_default_layout_is_three_buttons_on_the_right() {
let layout = Layout::default();
assert!(layout.left.is_empty());
assert_eq!(layout.right, vec![Button::Minimize, Button::Maximize, Button::Close]);
}
#[test]
fn parse_reads_gnomes_two_sided_layout() {
let layout = Layout::parse("appmenu:minimize,maximize,close");
assert!(layout.left.is_empty());
assert_eq!(layout.right, vec![Button::Minimize, Button::Maximize, Button::Close]);
let fedora = Layout::parse("appmenu:close");
assert_eq!(fedora.right, vec![Button::Close]);
let left = Layout::parse("close,minimize,maximize:");
assert_eq!(left.left, vec![Button::Close, Button::Minimize, Button::Maximize]);
assert!(left.right.is_empty());
}
#[test]
fn parse_drops_quotes_unknown_names_and_duplicates() {
// gsettings prints the value single-quoted.
let layout = Layout::parse("'appmenu:minimize,close,close,spacer'");
assert!(layout.left.is_empty());
assert_eq!(layout.right, vec![Button::Minimize, Button::Close]);
}
#[test]
fn parse_without_a_colon_puts_everything_on_the_left() {
let layout = Layout::parse("minimize,maximize,close");
assert_eq!(layout.left, vec![Button::Minimize, Button::Maximize, Button::Close]);
assert!(layout.right.is_empty());
}
#[test]
fn a_layout_naming_no_known_button_is_not_usable() {
assert!(!Layout::parse("appmenu:spacer").is_usable());
assert!(Layout::parse("appmenu:close").is_usable());
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn the_os_always_draws_the_frame_on_macos_and_windows() {
// A prefix whose variable no test or environment sets, so the platform decides.
let choice = super::Choice::probe(Prefix::new("CRAFT_WINDOW_UNSET_FOR_TEST"));
assert_eq!(choice.frame(), &Frame::Desktop);
assert_eq!(choice.overridden(), None);
}
}