diff --git a/Cargo.lock b/Cargo.lock index 5275a63f..91fb0a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6152,6 +6152,7 @@ dependencies = [ "block2 0.6.2", "dirs 6.0.0", "dunce", + "gtk", "ide-agent", "ide-analysis", "ide-build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ab942c55..633a43b9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -79,6 +79,10 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std # Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } +[target.'cfg(target_os = "linux")'.dependencies] +# Wayland detection and the client size without GTK's CSD shadow (linux_window_state.rs). +gtk = "0.18" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ "Win32_Foundation", diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index e68485c6..e383c9fa 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -15,6 +15,8 @@ pub struct AppInfo { pub version: String, pub platform: String, pub config_dir: ide_core::IpcPath, + /// Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. + pub wayland: bool, } pub use ide_host::ops::project::ProjectInfo; @@ -27,6 +29,10 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo { version: env!("CARGO_PKG_VERSION").into(), platform: std::env::consts::OS.into(), config_dir: state.config_dir.clone().into(), + #[cfg(target_os = "linux")] + wayland: crate::linux_window_state::is_wayland(), + #[cfg(not(target_os = "linux"))] + wayland: false, } } diff --git a/src-tauri/src/linux_window_state.rs b/src-tauri/src/linux_window_state.rs index 3d9816dc..a05a6f7e 100644 --- a/src-tauri/src/linux_window_state.rs +++ b/src-tauri/src/linux_window_state.rs @@ -14,6 +14,7 @@ mod runtime { use super::*; use parking_lot::Mutex; use std::collections::HashMap; + use std::sync::OnceLock; use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin}; const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME; @@ -63,8 +64,42 @@ mod runtime { visible: bool, } + static WAYLAND: OnceLock = OnceLock::new(); + + /// GDK's backend, as tao tests it (`GDK_BACKEND=x11` under a Wayland session is X11). + /// Known once the plugin is set up, before any window exists. + pub fn is_wayland() -> bool { + WAYLAND.get().copied().unwrap_or(false) + } + + /// The client size that `set_size` (`gtk_window_resize`) takes. On Wayland, decorated windows + /// get a GTK CSD shadow margin that tao's inner and outer sizes (the configure size) include. + pub fn content_size(window: &Window) -> tauri::Result> { + let inner = window.inner_size()?; + if !is_wayland() { + return Ok(inner); + } + // GTK objects belong to the main thread. `gtk_window_get_size` follows the configure + // synchronously; the webview's allocation only catches up on the next layout. + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let cloned = window.clone(); + window.run_on_main_thread(move || { + let sizes = cloned.gtk_window().ok().zip(cloned.inner_size().ok()).zip(cloned.scale_factor().ok()); + let _ = tx.send(sizes.map(|((gtk, inner), scale)| { + let (width, height) = gtk::prelude::GtkWindowExt::size(>k); + let content = tauri::LogicalSize::new(width.max(0) as u32, height.max(0) as u32); + (content.to_physical::(scale), inner) + })); + })?; + let Some((content, inner)) = rx.recv().ok().flatten() else { return Ok(inner) }; + // An unmapped window reports its size request; never exceed the configure size. + let usable = + content.width > 1 && content.height > 1 && content.width <= inner.width && content.height <= inner.height; + Ok(if usable { content } else { inner }) + } + fn frame(window: &Window) -> Option { - let size = window.inner_size().ok()?; + let size = content_size(window).ok()?; Some(Frame { visible: window.is_visible().ok()?, size: Size(size.width, size.height), @@ -381,6 +416,9 @@ mod runtime { pub fn plugin() -> TauriPlugin { tauri::plugin::Builder::new("linux-window-state") .setup(|app, _| { + // Plugins are set up on the main thread once GTK is initialised, before any window exists. + use gtk::gdk::prelude::DisplayExtManual; + let _ = WAYLAND.set(gtk::gdk::Display::default().is_some_and(|d| d.backend().is_wayland())); let saved = app .path() .app_config_dir() @@ -466,7 +504,7 @@ mod runtime { } #[cfg(target_os = "linux")] -pub use runtime::{normal_size_requested, plugin, prepare, save_before_close}; +pub use runtime::{content_size, is_wayland, normal_size_requested, plugin, prepare, save_before_close}; #[cfg(test)] mod tests { diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index fef00fa1..64344e0d 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -65,6 +65,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { if !maximized { to.unmaximize().map_err(err)?; to.set_position(from.outer_position().map_err(err)?).map_err(err)?; + // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. + #[cfg(target_os = "linux")] + let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; + #[cfg(not(target_os = "linux"))] let size = from.inner_size().map_err(err)?; #[cfg(target_os = "linux")] crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index a47340ad..14e84c20 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -303,6 +303,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< let window = WebviewWindowBuilder::from_config(app, &config) .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; + #[cfg(target_os = "linux")] + decorate_csd(&window); apply_zoom(app, &window); #[cfg(target_os = "macos")] align_titlebar(app, &window); @@ -325,6 +327,17 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< Ok(window) } +/// Wayland: tao already makes frameless windows client-side decorated (an empty GTK title bar, so +/// the compositor adds no frame), but undecorated GTK windows get no shadow or resize margin. +/// Decorating keeps that empty title bar, so GTK adds the theme's shadow and resize edges and +/// nothing else. X11 has no such title bar: decorating there would bring back the WM frame. +#[cfg(target_os = "linux")] +fn decorate_csd(window: &WebviewWindow) { + if crate::linux_window_state::is_wayland() { + let _ = window.set_decorations(true); + } +} + /// Sets the saved IDE zoom before the page loads; applied later by the frontend, /// it would visibly scale up the splash. fn apply_zoom(app: &AppHandle, window: &WebviewWindow) { @@ -807,6 +820,8 @@ pub fn open_settings_window( let window = builder .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; + #[cfg(target_os = "linux")] + decorate_csd(&window); apply_zoom(app, &window); #[cfg(target_os = "macos")] align_titlebar(app, &window); @@ -887,7 +902,12 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind let size = parent.outer_size()?.to_logical::(scale); let own_scale = window.scale_factor()?; let own = window.outer_size()?.to_logical::(own_scale); - let inner = window.inner_size()?.to_logical::(own_scale); + // On Wayland, the frame is GTK's CSD shadow margin, which tao's inner size includes. + #[cfg(target_os = "linux")] + let inner = crate::linux_window_state::content_size(&window.as_ref().window())?; + #[cfg(not(target_os = "linux"))] + let inner = window.inner_size()?; + let inner = inner.to_logical::(own_scale); let frame = (own.width - inner.width, own.height - inner.height); let bounds = fit_bounds( (pos.x, pos.y, size.width, size.height), diff --git a/src/bindings.ts b/src/bindings.ts index 7c4a2d95..eed10ae6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1379,6 +1379,8 @@ export type AppInfo = { version: string, platform: string, configDir: IpcPath, + /** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */ + wayland: boolean, }; export type AppMetrics = { diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 7661a3a6..d7bad042 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -203,8 +203,8 @@ background: var(--bg-dialog, var(--bg-elev)); color: var(--fg); } -/* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated - windows no CSD), so mark its edge in CSS instead. */ +/* Linux: on Wayland GTK draws the frameless window's shadow (`decorate_csd` in + windows.rs), but frameless X11 windows have none, so mark the edge in CSS. */ .windowEdge { position: relative; } diff --git a/src/platform/platformInfo.ts b/src/platform/platformInfo.ts index 230a34bd..95846480 100644 --- a/src/platform/platformInfo.ts +++ b/src/platform/platformInfo.ts @@ -9,6 +9,8 @@ interface PlatformInfo { overlayTitleBar: boolean; /** Frameless window: the title bar draws the main menu and caption buttons (Tauri, Windows and Linux). */ framelessTitleBar: boolean; + /** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */ + wayland: boolean; } /** OS from the user agent (before the backend answers, or in a browser). "darwin" must not read as Windows. */ @@ -28,6 +30,7 @@ export const usePlatform = create(() => { version: null, overlayTitleBar: isTauri() && os === "macos", framelessTitleBar: isTauri() && (os === "windows" || os === "linux"), + wayland: false, }; }); @@ -40,6 +43,7 @@ export async function loadPlatformInfo() { version: info.version, overlayTitleBar: info.platform === "macos", framelessTitleBar: info.platform === "windows" || info.platform === "linux", + wayland: info.wayland, }); } catch { /* keep the user-agent guess */ diff --git a/src/workbench/WindowResizeHandles.tsx b/src/workbench/WindowResizeHandles.tsx index 0b5fa9da..dbaf4e8f 100644 --- a/src/workbench/WindowResizeHandles.tsx +++ b/src/workbench/WindowResizeHandles.tsx @@ -1,11 +1,12 @@ /** - * Frameless-window resize handles: Windows gives undecorated windows no - * resize borders, so the title bar's frameless look leaves the window - * stuck at its launch size. Eight invisible hit areas along the edges + * Frameless-window resize handles: Windows and Linux on X11 give undecorated + * windows no resize borders, so the title bar's frameless look leaves the + * window stuck at its launch size. Eight invisible hit areas along the edges * start a native resize drag, like any other desktop app. * * macOS renders its own overlay title bar but still provides resize - * handles, so nothing is added there. + * handles, and on Wayland GTK draws resize edges in the client-side shadow + * (`decorate_csd` in windows.rs), so nothing is added there. */ import { useEffect, useState } from "react"; import { usePlatform } from "@/platform/platformInfo"; @@ -33,7 +34,8 @@ async function start(dir: Dir) { export function WindowResizeHandles() { // A frameless window needs them; a decorated one already has native borders. const frameless = usePlatform((s) => s.framelessTitleBar); - const enabled = isTauri() && frameless; + const gtkEdges = usePlatform((s) => s.os === "linux" && s.wayland); + const enabled = isTauri() && frameless && !gtkEdges; // A maximized or full-screen window has nothing to resize; the edges belong to the content. const [sized, setSized] = useState(false); useEffect(() => {