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 <[email protected]>
Цей коміт міститься в:
Piettro CastroтаClaude Opus 5.5 committed 2026-11-03 00:14:14 -05:00
1 джерело dc14a46a17
коміт ec5302d838
10 files changed
+91 -10

No files matched your search

Створено
+1
Переглянути файл
@@ -6152,6 +6152,7 @@ dependencies = [
"block2 0.6.2",
"dirs 6.0.0",
"dunce",
"gtk",
"ide-agent",
"ide-analysis",
"ide-build",
+4
Переглянути файл
@@ -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",
+6
Переглянути файл
@@ -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,
}
}
+40 -2
Переглянути файл
@@ -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<bool> = 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<tauri::PhysicalSize<u32>> {
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(&gtk);
let content = tauri::LogicalSize::new(width.max(0) as u32, height.max(0) as u32);
(content.to_physical::<u32>(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<Frame> {
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::Wry> {
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 {
+4
Переглянути файл
@@ -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);
+21 -1
Переглянути файл
@@ -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::<f64>(scale);
let own_scale = window.scale_factor()?;
let own = window.outer_size()?.to_logical::<f64>(own_scale);
let inner = window.inner_size()?.to_logical::<f64>(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::<f64>(own_scale);
let frame = (own.width - inner.width, own.height - inner.height);
let bounds = fit_bounds(
(pos.x, pos.y, size.width, size.height),
+2
Переглянути файл
@@ -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 = {
+2 -2
Переглянути файл
@@ -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;
}
+4
Переглянути файл
@@ -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<PlatformInfo>(() => {
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 */
+7 -5
Переглянути файл
@@ -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(() => {