Merge pull request #2 from pcastro93/wayland-csd-shadow

fix(linux): draw GTK's CSD shadow on frameless windows under Wayland
This commit is contained in:
arookieofc
2026-11-03 13:27:35 +08:00
committed by GitHub
10 changed files with 91 additions and 10 deletions
Generated
+1
View File
@@ -6152,6 +6152,7 @@ dependencies = [
"block2 0.6.2",
"dirs 6.0.0",
"dunce",
"gtk",
"ide-agent",
"ide-analysis",
"ide-build",
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = {
@@ -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
View File
@@ -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
View File
@@ -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(() => {