diff --git a/Cargo.lock b/Cargo.lock index bc60687e..9baacd58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1422,11 +1422,14 @@ dependencies = [ "fmv-macos-events", "image", "log", + "raw-window-handle", "rfd", "serde_json", "wat", + "wayland-client", "webbrowser", "windows", + "winit", "winresource", ] @@ -1600,7 +1603,7 @@ dependencies = [ "krilla", "log", "thiserror 2.0.21", - "tiny-skia", + "tiny-skia 0.12.0", ] [[package]] @@ -1631,7 +1634,7 @@ dependencies = [ "moxcms", "png", "rayon", - "tiny-skia", + "tiny-skia 0.12.0", ] [[package]] @@ -2570,7 +2573,7 @@ dependencies = [ "skrifa 0.42.1", "smallvec", "subsetter", - "tiny-skia-path", + "tiny-skia-path 0.12.0", "xmp-writer", "yoke", "zune-jpeg", @@ -4018,6 +4021,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4477,6 +4491,20 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -4489,7 +4517,18 @@ dependencies = [ "cfg-if", "log", "png", - "tiny-skia-path", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", ] [[package]] @@ -4778,7 +4817,7 @@ dependencies = [ "skrifa 0.44.0", "strict-num", "svgtypes", - "tiny-skia-path", + "tiny-skia-path 0.12.0", "unicode-bidi", "unicode-script", "unicode-vo", @@ -5731,6 +5770,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", diff --git a/NOTICE b/NOTICE index 9aaba825..a0baaa27 100644 --- a/NOTICE +++ b/NOTICE @@ -20,3 +20,34 @@ licence texts. Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and similar permissive licenses); see each crate's metadata. + +Linux Wayland window decorations +------------------------------- +sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita, +commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux +for vector window controls. Its title-font features are disabled: no Cantarell +font is embedded by this dependency and no font file is added to this repo. + +sctk-adwaita's MIT licence: + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index fcc5d03a..fac4842b 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -43,3 +43,9 @@ wat = { workspace = true } [build-dependencies] winresource = "0.1" + +[target.'cfg(target_os = "linux")'.dependencies] +# Use vector window controls without a title-font renderer or embedded Cantarell. +winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } +raw-window-handle = "0.6" +wayland-client = "0.31" diff --git a/apps/figcraft/src/linux_window.rs b/apps/figcraft/src/linux_window.rs new file mode 100644 index 00000000..f47b61e0 --- /dev/null +++ b/apps/figcraft/src/linux_window.rs @@ -0,0 +1,112 @@ +//! Request winit's frame only on Wayland compositors without xdg-decoration. +//! Desktop/session names cannot identify the backend of an individual window. + +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; + +use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +use wayland_client::protocol::wl_registry; +use wayland_client::{Connection, Dispatch, QueueHandle}; + +/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET. +/// Never consume, duplicate or query that inherited connection: there may be no +/// independently addressable display, or WAYLAND_DISPLAY may name another server. +pub fn display_socket() -> Option { + if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) { + return None; + } + let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?; + let path = PathBuf::from(display); + if path.is_absolute() { + Some(path) + } else { + std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path)) + } +} + +pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option) -> bool { + let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_))); + if !wayland { + log::debug!("window controls: non-Wayland window, retaining custom controls"); + return false; + } + let server_decorations = socket.and_then(probe); + log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}"); + native_frame(wayland, server_decorations) +} + +fn native_frame(wayland: bool, server_decorations: Option) -> bool { + wayland && server_decorations == Some(false) +} + +fn probe(socket: PathBuf) -> Option { + // The compositor must not be able to block startup on a registry roundtrip. + // On failure/timeout retain FigCraft's own working title bar and buttons. + let (send, receive) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("wayland-decorations".into()) + .spawn(move || { + let _ = send.send(registry_has_decorations(socket)); + }) + .ok()?; + receive.recv_timeout(Duration::from_millis(500)).ok().flatten() +} + +fn registry_has_decorations(socket: PathBuf) -> Option { + // A fresh connection leaves winit's event queue and inherited descriptors alone. + let stream = UnixStream::connect(socket).ok()?; + let connection = Connection::from_socket(stream).ok()?; + let (globals, _queue) = registry_queue_init::(&connection).ok()?; + Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"))) +} + +struct Registry; + +impl Dispatch for Registry { + fn event( + _state: &mut Self, + _registry: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + _queue: &QueueHandle, + ) { + } +} + +#[cfg(test)] +mod tests { + #[test] + fn only_wayland_without_server_decorations_uses_the_frame() { + for (wayland, server_decorations, expected) in [ + (false, None, false), + (false, Some(false), false), + (false, Some(true), false), + (true, Some(true), false), + (true, Some(false), true), + (true, None, false), + ] { + assert_eq!(super::native_frame(wayland, server_decorations), expected); + } + } + + #[test] + fn an_unavailable_display_keeps_the_custom_controls() { + assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None); + } + + #[test] + fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> { + let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id())); + let listener = std::os::unix::net::UnixListener::bind(&socket)?; + let start = std::time::Instant::now(); + assert_eq!(super::probe(socket.clone()), None); + assert!(start.elapsed() < std::time::Duration::from_secs(2)); + drop(listener); + let _ = std::fs::remove_file(socket); + Ok(()) + } +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index fdcc66f9..628a4858 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -15,6 +15,8 @@ mod args; mod control_server; mod gamepad; mod graphics; +#[cfg(target_os = "linux")] +mod linux_window; mod recovery; mod services; mod startup; @@ -192,21 +194,18 @@ fn main() -> eframe::Result { .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); - // The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the - // window is frameless and FigCraft draws the buttons and resize edges. - if cfg!(target_os = "macos") { - viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); - fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset; - } else { - viewport = viewport.with_decorations(false); - fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom; - } + // Start frameless on Linux; the app creator checks the actual display backend + // before enabling a client-side frame. Other windows inherit that decision. + fig.ui.title_bar = desktop_title_bar(); + viewport = window_chrome(viewport, fig.ui.title_bar); if let Some(icon) = icon { viewport = viewport.with_icon(icon); } let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); + #[cfg(target_os = "linux")] + let linux_display = linux_window::display_socket(); // Registered before the event loop starts, so it catches the Finder event that launched the app // as well as later ones. Lives until the event loop returns; the app creator only borrows it. #[cfg(target_os = "macos")] @@ -217,6 +216,11 @@ fn main() -> eframe::Result { "FigCraft", options, Box::new(move |_cc| { + #[cfg(target_os = "linux")] + if linux_window::needs_native_frame(_cc, linux_display) { + let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native); + _cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true)); + } Ok(Box::new(App { queue, ctx_slot, @@ -233,3 +237,28 @@ fn main() -> eframe::Result { } result } + +fn desktop_title_bar() -> figcraft_ui_egui::TitleBar { + use figcraft_ui_egui::TitleBar; + if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom } +} + +fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder { + use figcraft_ui_egui::TitleBar; + match title_bar { + TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false), + TitleBar::Native => viewport.with_decorations(true), + TitleBar::Custom => viewport.with_decorations(false), + } +} + +#[cfg(all(test, target_os = "linux"))] +mod linux_window_tests { + #[test] + fn linux_starts_with_the_existing_frameless_title_bar() { + let bar = super::desktop_title_bar(); + assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.decorations, Some(false)); + } +}