From ec5302d8385da9c68f13975c8733165818374a4d Mon Sep 17 00:00:00 2001 From: Piettro Castro Date: Tue, 3 Nov 2026 00:14:14 -0500 Subject: [PATCH] fix(linux): draw GTK's CSD shadow on frameless windows under Wayland tao makes frameless Wayland windows client-side decorated (an empty GTK title bar, so the compositor adds no frame) but leaves them undecorated, so GTK draws no shadow or resize margin and dark windows blend into whatever is behind them. Decorating the window after build keeps that empty title bar: GTK adds the theme's shadow and resize edges, nothing else. Applies to project windows and the Settings window; X11 is unchanged (decorating there would bring back the WM frame). - Detect Wayland from GDK's backend (tao's own test), not env vars. - Measure windows by GTK's content size, which excludes the shadow margin, so remembered geometry and Settings placement don't grow. - Skip the in-page resize handles on Wayland; GTK provides the edges. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + src-tauri/Cargo.toml | 4 ++ src-tauri/src/commands/app.rs | 6 +++ src-tauri/src/linux_window_state.rs | 42 ++++++++++++++++++- src-tauri/src/window_groups.rs | 4 ++ src-tauri/src/windows.rs | 22 +++++++++- src/bindings.ts | 2 + .../settings/dialog/SettingsDialog.module.css | 4 +- src/platform/platformInfo.ts | 4 ++ src/workbench/WindowResizeHandles.tsx | 12 +++--- 10 files changed, 91 insertions(+), 10 deletions(-) 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(() => {