diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt index 4f415c900..81fecf738 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt @@ -34,6 +34,7 @@ import dev.nucleusframework.window.LocalControlButtonsDirection import dev.nucleusframework.window.TitleBarScope import dev.nucleusframework.window.styling.LocalTitleBarStyle import dev.nucleusframework.window.styling.TitleBarStyle +import dev.nucleusframework.window.tao.HideNativeLinuxTitlebarWhileComposed import dev.nucleusframework.window.tao.LocalRequestedTitleBarHeight import dev.nucleusframework.window.tao.TaoDecoratedDialogScope import dev.nucleusframework.window.tao.ffi.NativeMetalBridge @@ -71,6 +72,7 @@ public fun DecoratedDialogScope.DialogTitleBar( ) { val taoScope = this as TaoDecoratedDialogScope val taoWindow = taoScope.window + HideNativeLinuxTitlebarWhileComposed(taoWindow) val dialogState = taoScope.state val windowState = dialogState.toDecoratedWindowState() val controlDir = controlButtonsDirection.resolve() diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt index ab437dd30..3b08aae97 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt @@ -39,6 +39,7 @@ import dev.nucleusframework.window.hasMacOSLargeCornerRadius import dev.nucleusframework.window.hasNewFullscreenControls import dev.nucleusframework.window.styling.LocalTitleBarStyle import dev.nucleusframework.window.styling.TitleBarStyle +import dev.nucleusframework.window.tao.HideNativeLinuxTitlebarWhileComposed import dev.nucleusframework.window.tao.LocalRequestedTitleBarHeight import dev.nucleusframework.window.tao.LocalWindowClearColorLayers import dev.nucleusframework.window.tao.TaoDecoratedWindowScope @@ -122,6 +123,7 @@ public fun DecoratedWindowScope.BasicTitleBar( val taoScope = this as TaoDecoratedWindowScope val taoWindow = taoScope.window val currentState = taoScope.state + HideNativeLinuxTitlebarWhileComposed(taoWindow) // Publish the resolved height up to DecoratedWindow, which applies the // native button-centering constraints once the window is shown. diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowScaffold.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowScaffold.kt index 755e34b4a..5236a191a 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowScaffold.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowScaffold.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.HideNativeLinuxTitlebarWhileComposed import dev.nucleusframework.window.tao.LocalRequestedTitleBarHeight import dev.nucleusframework.window.tao.TaoDecoratedWindowScope import dev.nucleusframework.window.tao.ffi.NativeMetalBridge @@ -63,6 +64,9 @@ public fun DecoratedWindowScope.WindowScaffold( val taoScope = this as TaoDecoratedWindowScope val taoWindow = taoScope.window val currentState = taoScope.state + if (titleBar != null) { + HideNativeLinuxTitlebarWhileComposed(taoWindow) + } val heightHolder = LocalRequestedTitleBarHeight.current val density = LocalDensity.current diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt index 99f9bcfa9..6f6b79cb6 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt @@ -263,6 +263,7 @@ internal fun ApplicationScope.openDecoratedWindow( initialCompositionLocalContext: CompositionLocalContext? = null, content: @Composable TaoDecoratedWindowScope.() -> Unit, ): TaoWindow { + val nativeLinuxDecorations = linuxKeepsNativeWindowDecorations(undecorated) // hiddenFromDock rides on the GTK skip-taskbar/skip-pager hint, which // native Wayland does not honour: there is no client-side skip-taskbar // protocol on Wayland (xdg-shell, gtk_shell1 and the staging extensions all @@ -288,13 +289,17 @@ internal fun ApplicationScope.openDecoratedWindow( width = width, height = height, // On macOS we keep native decorations (traffic-light buttons live there). - // On Windows + Linux we drop them — we draw the close/min/max buttons - // ourselves via [WindowControlsWindows] / [WindowControlsLinux] inside - // the user's [TitleBar] composable, mirroring decorated-window-jni. - // `undecorated` opts out entirely (borderless, no traffic lights). - // Linux still gets the native GTK drop shadow through - // `undecoratedShadow` below (yaru.dart-style hidden-titlebar CSD). - decorations = !undecorated && Platform.Current == Platform.MacOS, + // On Windows we drop them — we draw the close/min/max buttons + // ourselves via [WindowControlsWindows] inside the user's [TitleBar]. + // On Linux the default is Compose chrome + (Wayland-only) yaru-style + // hidden-titlebar CSD for the GTK shadow ring. + // KDE/Plasma is the exception: hiding the GTK header latches CSD + // and KWin then drops server-side decorations, so the window is + // left with no title bar, borders, or caption buttons + // (ComposeNativeTray #425). Keep native decorations there instead. + decorations = + !undecorated && + (Platform.Current == Platform.MacOS || nativeLinuxDecorations), resizable = resizable, visible = false, // we show after first paint // Pass `maximized` to the builder so Tao sets it BEFORE the window @@ -313,8 +318,10 @@ internal fun ApplicationScope.openDecoratedWindow( transparent = transparent, // Tao defaults borderless windows to a drop shadow (DWM on // Windows, NSWindow.hasShadow on macOS). Overlays must opt out - // or the ghost still shows a soft contour. - undecoratedShadow = !undecorated, + // or the ghost still shows a soft contour. On KDE this flag + // would also latch hidden-titlebar CSD and strip the native + // frame we just asked for above. + undecoratedShadow = !undecorated && !nativeLinuxDecorations, ) // Compose Hot Reload: the agent only auto-wraps AWT `ComposeWindow`/ @@ -602,8 +609,9 @@ internal fun ApplicationScope.openDecoratedWindow( /** * Linux path for [DecoratedWindow]: EGL renderer attached to the GTK-owned * surface (X11 XID or wl_surface, picked at runtime by [TaoComposeSceneHostLinux]). - * Native GTK decorations are kept; the user's [TitleBar] composable still - * works as a sub-bar inside the content area. + * On KDE the native GTK/KWin frame is kept (#425). Elsewhere the yaru + * hidden-titlebar CSD is used; a composed [TitleBar] still hides that + * native header so custom chrome does not double up. */ @Suppress("FunctionNaming", "LongParameterList", "LongMethod", "CyclomaticComplexMethod") private fun ApplicationScope.openDecoratedWindowLinux( @@ -633,8 +641,9 @@ private fun ApplicationScope.openDecoratedWindowLinux( // Yaru-style hidden-titlebar CSD (native GTK shadow ring): created via // `undecoratedShadow = !undecorated` at openWindow time; the host aligns // the frame radius and extends the resize band over the ring. Only - // effective on Wayland non-popup windows. - host.nativeCsdDecorations = !undecorated + // effective on Wayland non-popup windows. Off on KDE: native decorations + // keep a visible GTK/KWin titlebar instead of the hidden-header CSD. + host.nativeCsdDecorations = !undecorated && !linuxKeepsNativeWindowDecorations(undecorated) host.setSceneCompositionLocalContext(initialCompositionLocalContext) // ── Linux accessibility (AT-SPI2 via AccessKit) ──────────────────────── @@ -709,8 +718,9 @@ private fun ApplicationScope.openDecoratedWindowLinux( ) { // Default: CSD outline (vanilla-style frame for custom chrome). // `undecorated` = fully borderless overlay — no Compose stroke. + // KDE keeps the native frame, so a Compose stroke would double it. val border = - if (undecorated) { + if (undecorated || linuxKeepsNativeWindowDecorations(undecorated)) { Modifier } else { rememberUndecoratedWindowBorder( @@ -1425,3 +1435,18 @@ private fun resolveChromeDark( // Same Rec.601 luminance split the other backends use. dev.nucleusframework.window.WindowAppearanceMode.System -> Color(backgroundArgb).isDark() } + +/** + * KDE/Plasma must keep the native GTK/KWin frame when the caller asked for + * decorations. The yaru hidden-titlebar CSD used on GNOME latches client-side + * decorations; KWin then stops drawing SSD and a hidden header leaves the + * window with no title bar, borders, or caption buttons + * (ComposeNativeTray #425). + */ +internal fun linuxKeepsNativeWindowDecorations( + undecorated: Boolean, + desktop: LinuxDesktopEnvironment = LinuxDesktopEnvironment.Current, +): Boolean = + !undecorated && + Platform.Current == Platform.Linux && + desktop == LinuxDesktopEnvironment.KDE diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/LinuxNativeTitlebar.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/LinuxNativeTitlebar.kt new file mode 100644 index 000000000..4bab84a4f --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/LinuxNativeTitlebar.kt @@ -0,0 +1,43 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import java.util.concurrent.ConcurrentHashMap + +/** + * Hides the native GTK/KWin titlebar while custom Compose chrome ([TitleBar], + * [dev.nucleusframework.window.WindowScaffold], [dev.nucleusframework.window.DialogTitleBar]) + * is composed. Without this, KDE would show both the native frame we keep for + * #425 and the in-content bar. + * + * No-op on desktops that already use the hidden-titlebar CSD path. + */ +@Composable +internal fun HideNativeLinuxTitlebarWhileComposed(window: TaoWindow) { + if (!linuxKeepsNativeWindowDecorations(undecorated = false)) return + DisposableEffect(window.handle) { + claimNativeLinuxTitlebarHidden(window) + onDispose { releaseNativeLinuxTitlebarHidden(window) } + } +} + +private val hideClaims = ConcurrentHashMap() + +private fun claimNativeLinuxTitlebarHidden(window: TaoWindow) { + val next = hideClaims.merge(window.handle, 1) { current, _ -> current + 1 } ?: 1 + if (next == 1) { + NativeTaoBridge.nativeLinuxSetTitlebarVisible(window.handle, false) + } +} + +private fun releaseNativeLinuxTitlebarHidden(window: TaoWindow) { + val remaining = + hideClaims.compute(window.handle) { _, current -> + val next = (current ?: 0) - 1 + if (next <= 0) null else next + } + if (remaining == null) { + NativeTaoBridge.nativeLinuxSetTitlebarVisible(window.handle, true) + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt index 1976a98ba..440411d0e 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt @@ -345,6 +345,19 @@ internal object NativeTaoBridge { ownerHandle: Long, ) + /** + * Linux only: show or hide the widget installed as `gtk_window_set_titlebar` + * (tao's Wayland [WlHeader] or the yaru hidden header). Hiding sets + * `no-show-all` so a later `gtk_widget_show_all` does not bring it back. + * Used on KDE to drop the native frame while a Compose [TitleBar] is + * composed, without unlatching CSD. No-op when the handle has no titlebar. + */ + @JvmStatic + external fun nativeLinuxSetTitlebarVisible( + handle: Long, + visible: Boolean, + ) + /** * Linux only: returns `[x, y, width, height]` of the window's outer * (decoration-inclusive) bounds in physical pixels with a top-left origin. diff --git a/decorated-window-tao/src/main/native/src/platform/linux/decoration.rs b/decorated-window-tao/src/main/native/src/platform/linux/decoration.rs index be7caa341..294a63be5 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/decoration.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/decoration.rs @@ -18,7 +18,7 @@ // `WINDOWS` map, exactly like `monitor.rs` / `handles.rs`. use jni::objects::JClass; -use jni::sys::{jlong, jlongArray}; +use jni::sys::{jboolean, jlong, jlongArray}; use jni::JNIEnv; use tao::platform::unix::WindowExtUnix; @@ -104,3 +104,31 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ } arr.into_raw() } + +/// Shows or hides the GTK titlebar widget. Hiding also sets `no-show-all` so +/// tao's `show_all()` path cannot resurrect a 44 px native header above +/// Compose chrome. CSD stays latched because `gtk_window_set_titlebar` is +/// not cleared — KWin still sees a client-decorated window. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeLinuxSetTitlebarVisible( + _env: JNIEnv, + _class: JClass, + handle: jlong, + visible: jboolean, +) { + use gtk::prelude::{GtkWindowExt, WidgetExt}; + + let Some(window) = with_window(handle, |window| Some(window.gtk_window().clone())) else { + return; + }; + let Some(titlebar) = window.titlebar() else { + return; + }; + if visible != 0 { + titlebar.set_no_show_all(false); + titlebar.show(); + } else { + titlebar.hide(); + titlebar.set_no_show_all(true); + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/LinuxNativeDecorationsTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/LinuxNativeDecorationsTest.kt new file mode 100644 index 000000000..f9ad388de --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/LinuxNativeDecorationsTest.kt @@ -0,0 +1,27 @@ +package dev.nucleusframework.window.tao + +import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment +import dev.nucleusframework.core.runtime.Platform +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LinuxNativeDecorationsTest { + @Test + fun `KDE keeps native decorations when undecorated is false`() { + if (Platform.Current != Platform.Linux) return + assertTrue(linuxKeepsNativeWindowDecorations(undecorated = false, desktop = LinuxDesktopEnvironment.KDE)) + } + + @Test + fun `KDE still allows a borderless overlay`() { + if (Platform.Current != Platform.Linux) return + assertFalse(linuxKeepsNativeWindowDecorations(undecorated = true, desktop = LinuxDesktopEnvironment.KDE)) + } + + @Test + fun `GNOME keeps the hidden-titlebar CSD path`() { + if (Platform.Current != Platform.Linux) return + assertFalse(linuxKeepsNativeWindowDecorations(undecorated = false, desktop = LinuxDesktopEnvironment.Gnome)) + } +}