M3: draw a GNOME-style title bar on Wayland (#553) (#1530)

GNOME's Wayland compositor draws no window frames (it offers no
zxdg_decoration_manager_v1), so winit drew its own fallback title bar,
which ignores GNOME's theme, dark mode and button layout. PdfCraft now
asks the compositor at launch (apps/pdfcraft/src/decorations.rs) and,
where it draws no frames, turns winit's frame off and makes the tab strip
the header bar, as in GNOME's own apps
(crates/ui-egui/src/window_controls.rs):

- Minimize, maximize/restore and close in Adwaita's style (round buttons,
  symbolic glyphs drawn in code, no new assets), in the order and on the
  side named by org.gnome.desktop.wm.preferences button-layout, read
  through gsettings; Fedora's default `appmenu:close` shows only close.
  Hidden in full screen.
- Resize zones along the window's edges and L-shaped corners, above the
  rest of the UI so a press there resizes instead of reaching a
  scrollbar; a hairline outlines the window while it isn't maximized.
- Preferences > Use system title bar (shown only where the app draws the
  frame) gives the window back winit's frame at once, without a restart
  (part of #1001). Named as in VectorCraft and PhotoCraft, but GNOME draws
  no title bars for apps, so on GNOME this is winit's basic frame, not
  GNOME's own; the tooltip says so. Saved as `system_title_bar`;
  `system-title-bar` on the control channel.
- PDFCRAFT_TITLEBAR=app|system overrides the detection.

KDE, Sway, Hyprland, COSMIC and X11 offer server-side decorations and
keep the frame their desktop draws; macOS and Windows are unchanged.

Not done: right-clicking the header bar doesn't open GNOME's window menu
(egui has no command for it), the window has no shadow or rounded
corners, the resize zones lie just inside the window rather than outside
it, and Windows still uses its system frame (the rest of #1001).

The new labels (Minimize, Maximize, Restore, Use system title bar and its
tooltip) were added to all 21 translation catalogs. These translations
have not been checked by anyone fluent in those languages and should be
reviewed. The catalogs' hashes in ATTRIBUTION.toml are updated.

Made with Claude Code (Claude Opus 5.5, medium effort). It passes the
workspace quality gates (fmt, clippy, cargo test --workspace, xtask
assets) and was checked by hand on Fedora 44 GNOME (light and dark,
maximized and not, the preference toggled live), but it has not been
exhaustively regression tested.

Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
bmh129
2026-10-13 03:04:13 -04:00
committed by GitHub
co-authored by bmh129 Claude Opus 5.5 Brandon Thomas
parent 7739685796
commit 7437bde378
32 changed files with 642 additions and 33 deletions
+70
View File
@@ -0,0 +1,70 @@
//! Who draws the window frame on Linux.
//!
//! Wayland compositors that draw frames for their clients offer `zxdg_decoration_manager_v1`
//! (KDE, Sway, Hyprland, COSMIC). GNOME doesn't: it leaves the frame to the app, and winit then
//! drew an imitation of GNOME's. Without that protocol PdfCraft turns winit's frame off and draws
//! a header bar like GNOME's own apps (`pdfcraft_ui_egui::window_controls`), with the window
//! buttons GNOME's `button-layout` names. X11 window managers always draw the frame.
//!
//! `PDFCRAFT_TITLEBAR=app` or `=system` overrides the choice.
use pdfcraft_ui_egui::window_controls::Layout;
/// The window buttons to draw in the app, or `None` when the desktop draws the frame.
pub(crate) fn app_drawn() -> Option<Layout> {
let drawn = match std::env::var("PDFCRAFT_TITLEBAR").ok().as_deref().map(str::trim) {
Some("app") => true,
Some("system") => false,
_ => compositor_leaves_frame_to_app(),
};
drawn.then(button_layout)
}
/// GNOME's button order, or minimize, maximize and close on the right when it names none.
fn button_layout() -> Layout {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
if let Some(value) = crate::text_scale::gsettings(&["get", "org.gnome.desktop.wm.preferences", "button-layout"]) {
let layout = Layout::parse(&value);
// A layout without a single button would leave no way to close the window by mouse.
if !layout.left.is_empty() || !layout.right.is_empty() {
return layout;
}
}
Layout::default()
}
/// Whether this is a Wayland session whose compositor draws no frames.
#[cfg(target_os = "linux")]
fn compositor_leaves_frame_to_app() -> bool {
use wayland_client::Connection;
use wayland_client::globals::registry_queue_init;
// No Wayland compositor: X11, whose window manager draws the frame (winit falls back to it).
let Ok(conn) = Connection::connect_to_env() else { return false };
let Ok((globals, _queue)) = registry_queue_init::<Registry>(&conn) else { return false };
let offered = globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"));
if !offered {
log::info!("the Wayland compositor draws no window frames; PdfCraft draws its own title bar");
}
!offered
}
#[cfg(not(target_os = "linux"))]
fn compositor_leaves_frame_to_app() -> bool {
false
}
#[cfg(target_os = "linux")]
struct Registry;
#[cfg(target_os = "linux")]
impl wayland_client::Dispatch<wayland_client::protocol::wl_registry::WlRegistry, wayland_client::globals::GlobalListContents> for Registry {
fn event(
_: &mut Self,
_: &wayland_client::protocol::wl_registry::WlRegistry,
_: wayland_client::protocol::wl_registry::Event,
_: &wayland_client::globals::GlobalListContents,
_: &wayland_client::Connection,
_: &wayland_client::QueueHandle<Self>,
) {
}
}
+26 -9
View File
@@ -32,6 +32,7 @@ use pdfcraft_ui_egui::PdfCraftApp;
#[cfg(target_os = "macos")]
mod apple_events;
mod compositor_gpu;
mod decorations;
mod logging;
#[cfg(target_os = "windows")]
mod motion;
@@ -199,6 +200,8 @@ fn main() -> eframe::Result {
}
let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file());
let integrated = cfg!(target_os = "macos");
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
migrate_legacy_folders();
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
@@ -220,7 +223,7 @@ fn main() -> eframe::Result {
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated };
let launch = Launch { files, options, control_file, create_images, integrated, window_controls };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
// that launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -230,7 +233,7 @@ fn main() -> eframe::Result {
let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu };
let result = eframe::run_native(
"PdfCraft",
native_options(integrated, first),
native_options(launch.integrated, launch.window_controls.is_some(), first),
app_creator(
launch.clone(),
Rc::clone(&started),
@@ -248,7 +251,7 @@ fn main() -> eframe::Result {
log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu.");
eframe::run_native(
"PdfCraft",
native_options(integrated, eframe::Renderer::Glow),
native_options(launch.integrated, launch.window_controls.is_some(), eframe::Renderer::Glow),
app_creator(
launch,
started,
@@ -288,7 +291,7 @@ fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool {
}
/// The window and renderer settings for one run.
fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
@@ -304,6 +307,10 @@ fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::Nativ
if integrated {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
if app_frame {
// The tab strip is the title bar (pdfcraft_ui_egui::window_controls).
viewport = viewport.with_decorations(false);
}
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
// Set explicitly, so the renderer never depends on which one eframe defaults to.
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
@@ -321,6 +328,8 @@ struct Launch {
control_file: Option<String>,
create_images: bool,
integrated: bool,
/// The window buttons the app draws, when the desktop leaves the frame to it.
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
}
fn app_creator<'a>(
@@ -329,7 +338,7 @@ fn app_creator<'a>(
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated } = launch;
let Launch { files, options, control_file, create_images, integrated, window_controls } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@@ -343,6 +352,7 @@ fn app_creator<'a>(
app.restore(&json);
}
app.integrated_titlebar = integrated;
app.window_controls = window_controls;
app.system_text_scale = text_scale::system();
// Windows' Animation effects setting, which Preferences ▸ Animation effects follows unless
// it is set to On or Off.
@@ -805,8 +815,15 @@ mod tests {
assert!(!retry_with_gl(RendererChoice::Wgpu, false));
assert!(!retry_with_gl(RendererChoice::Gl, false));
// Each run states its renderer rather than relying on eframe's default.
assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
}
#[test]
fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
use super::native_options;
assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None);
assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
}
#[test]
@@ -957,7 +974,7 @@ mod tests {
// than the device allows.
use eframe::wgpu;
let _gpu = gpu();
let native = super::native_options(false, eframe::Renderer::Wgpu);
let native = super::native_options(false, false, eframe::Renderer::Wgpu);
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
panic!("default setup creates its own instance")
};
@@ -1024,7 +1041,7 @@ mod tests {
fn pipeline_creation_error_is_returned_and_device_remains_usable() {
use eframe::{egui_wgpu, wgpu};
let _gpu = gpu();
let mut native = super::native_options(false, eframe::Renderer::Wgpu);
let mut native = super::native_options(false, false, eframe::Renderer::Wgpu);
let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") };
let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance());
let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() };
+1 -1
View File
@@ -27,7 +27,7 @@ fn parse(output: &str) -> f32 {
/// Run `gsettings` and return what it printed, or `None` if it is missing, fails or hangs.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
fn gsettings(args: &[&str]) -> Option<String> {
pub(crate) fn gsettings(args: &[&str]) -> Option<String> {
use std::io::Read;
use std::process::{Command, Stdio};
let mut child = Command::new("gsettings").args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;