Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand 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
Expand All @@ -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`/
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) ────────────────────────
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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<Long, Int>()

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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading