Files
craft-libs/crates/window
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
..

craft-window

Decide who draws the window frame — the desktop or the app — so every Craft app shows the same titlebar and borders on a given desktop.

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.

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

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.

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.

Use

use craft_window::{Choice, Prefix, decorate};

// 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());

// 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
}

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.

The egui feature adds [decorate]. Without it, the crate is pure decision logic (Choice, Frame, Layout) with no egui dependency.

Notes

  • The Wayland probe opens a short-lived connection and reads the global registry once, on its own thread with a 500 ms timeout; on failure or timeout it keeps the desktop's frame. Call it once at startup.
  • It never touches the connection winit inherits: when WAYLAND_SOCKET is set (a compositor handed the app an open descriptor), the probe skips connecting and keeps the desktop frame, so it can't consume the socket winit needs.
  • The GNOME button-layout lookup (gsettings) runs behind the same bounded worker, so a stalled gsettings can't block startup either.
  • #![forbid(unsafe_code)], and the never-crash lints the workspace requires.