Merge pull request #607 from a-rookie-of-C-language/fix/settings-window-shadow-595

fix(windows): de-parent settings window so DWM paints its shadow
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-03 19:22:02 +08:00
коміт 867774b88a
13 files changed
+137 -15

No files matched your search

Створено
+1
Переглянути файл
@@ -6153,6 +6153,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
Переглянути файл
@@ -66,6 +66,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);
+28 -4
Переглянути файл
@@ -307,6 +307,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);
@@ -329,6 +331,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) {
@@ -800,13 +813,19 @@ pub fn open_settings_window(
#[cfg(target_os = "linux")]
crate::linux_window_state::prepare(app, &mut config);
let builder = WebviewWindowBuilder::from_config(app, &config);
// AppKit child windows can disappear when dragged to another display's Space.
// The settings label already tracks ownership and parent-close cleanup.
#[cfg(not(target_os = "macos"))]
// Windows: an owned child (WS_POPUP) of a borderless parent gets no DWM
// shadow, leaving the frameless settings window flat against its parent.
// Ownership is tracked by the `settings-<parent>` label instead
// (parent-close cleanup, menu modality), so only Linux keeps the native
// parent link. AppKit child windows can disappear when dragged to another
// display's Space, so macOS keeps it too.
#[cfg(target_os = "linux")]
let builder = builder.and_then(|b| b.parent(&parent));
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 +906,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
Переглянути файл
@@ -1390,6 +1390,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 = {
+20
Переглянути файл
@@ -22,6 +22,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
});
import { registerAction, executeAction } from "@/platform/actions";
import { usePlatform } from "@/platform/platformInfo";
import { useProject } from "@/platform/project";
import { useKeymap } from "@/platform/keymap";
import { ContextMenuHost } from "@/ui";
@@ -29,6 +30,8 @@ import { PageContext, useSetting } from "../components/context";
import { DEFAULT_SETTINGS, setAt } from "../model/schema";
import { openSettings, stage, useSettingsDialog } from "../model/staging";
import { SettingsDialog } from "../dialog/SettingsDialog";
import { SettingsWindow } from "../window/SettingsWindow";
import dialogStyles from "../dialog/SettingsDialog.module.css";
import { activate } from "..";
import { MessagesHost } from "@/workbench/messages";
@@ -471,6 +474,23 @@ describe("Settings dialog", () => {
});
});
describe("native Settings window root", () => {
it("marks the window edge on Linux, whose frameless windows get no compositor shadow", () => {
const saved = usePlatform.getState();
usePlatform.setState({ os: "linux" });
let view = render(<SettingsWindow />);
let root = document.querySelector(`.${dialogStyles.window}`);
expect(root).toHaveClass(dialogStyles.windowEdge!);
view.unmount();
usePlatform.setState({ os: "windows" });
view = render(<SettingsWindow />);
root = document.querySelector(`.${dialogStyles.window}`);
expect(root).not.toHaveClass(dialogStyles.windowEdge!);
view.unmount();
usePlatform.setState(saved);
});
});
describe("native Settings window background", () => {
it.each([
["a hex frame", "#26282c", "", "#26282c"],
+13
Переглянути файл
@@ -203,6 +203,19 @@
background: var(--bg-dialog, var(--bg-elev));
color: var(--fg);
}
/* 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;
}
.windowEdge::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
box-shadow: inset 0 0 0 1px var(--border-window);
}
.windowBody {
flex: 1;
min-height: 0;
+4 -2
Переглянути файл
@@ -1,4 +1,5 @@
/** Content of the native Settings window: title bar, page tree + page, buttons. */
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { OverlayHost } from "@/workbench/OverlayHost";
import { usePlatform } from "@/platform/platformInfo";
@@ -10,6 +11,7 @@ import styles from "../dialog/SettingsDialog.module.css";
export function SettingsWindow() {
useApplyTheme();
const os = usePlatform((s) => s.os);
const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar);
const open = useSettingsDialog((s) => s.open);
const title = useSettingsTitle();
@@ -20,8 +22,8 @@ export function SettingsWindow() {
}, [open]);
return (
<div ref={ref} className={styles.window}>
{/* macOS overlays traffic lights on it; Windows is frameless. Linux keeps the OS title bar. */}
<div ref={ref} className={clsx(styles.window, os === "linux" && styles.windowEdge)}>
{/* macOS overlays traffic lights on it; Windows and Linux are frameless (tauri.<os>.conf.json). */}
{ownTitleBar && <TitleBar minimal dialog title={title} />}
{open && (
<>
+4 -2
Переглянути файл
@@ -1,8 +1,10 @@
/**
* Settings window ↔ its project window.
*
* Settings live in a native child window `settings-<parent>` (see
* src-tauri/src/windows.rs) that only runs the settings pages. The action
* Settings live in a separate window `settings-<parent>` (see
* src-tauri/src/windows.rs) that only runs the settings pages (a native
* child of the parent on macOS/Linux; top-level on Windows so DWM paints
* its shadow). The action
* registry stays in the project window: it sends the settings window a
* description of every action (Keymap page, links that run actions) and runs
* the actions the settings window forwards.
+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(() => {