mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
bmh129
Claude Opus 5.5
Brandon Thomas
parent
7739685796
commit
7437bde378
@@ -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>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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() };
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user