Skip to content
Closed
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 @@ -41,6 +41,8 @@ public fun NucleusApplicationScope.JewelDecoratedWindow(
popupFor: NucleusWindow? = null,
// Replace Compose-drawn context menus with the OS-looking menu: `NSMenu`
// on macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent).
// The flyout always opens in a native popup surface, whatever
// `nativePopupLayers` says.
nativeContextMenu: Boolean = false,
// Hide this window from the OS taskbar/Dock while it stays visible and
// focusable (on Linux effective on X11/XWayland only).
Expand All @@ -66,6 +68,17 @@ public fun NucleusApplicationScope.JewelDecoratedWindow(
// native Wayland session, for the window management Wayland has no protocol
// for (stacking, positioning, workspace stickiness). Creation-time only.
forceX11: Boolean = false,
// Materialise Compose Popup layers as native transparent windows
// (NSPanel / WS_POPUP HWND) instead of drawing them inline in this
// window's render target, so a popup can leave the window bounds.
//
// Jewel's own components get this for free: `LocalPopupRenderer`'s default
// renderer delegates to `androidx.compose.ui.window.Popup`, so every
// `ListComboBox`, `PopupMenu`, `Dropdown` and tooltip in this window flows
// through the native layers — including their screen-aware placement
// (#569), which keeps a combo box popup on the display when the window
// sits at its bottom edge. Supported on all three platforms.
nativePopupLayers: Boolean = false,
content: @Composable NucleusDecoratedWindowScope.() -> Unit,
) {
val windowStyle = rememberJewelWindowStyle()
Expand Down Expand Up @@ -94,6 +107,7 @@ public fun NucleusApplicationScope.JewelDecoratedWindow(
forceX11 = forceX11,
undecorated = undecorated,
popupFor = popupFor,
nativePopupLayers = nativePopupLayers,
nativeContextMenu = nativeContextMenu,
hiddenFromDock = hiddenFromDock,
minimumSize = minimumSize,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public fun NucleusApplicationScope.MaterialDecoratedWindow(
nativePopupLayers: Boolean = false,
// Replace Compose-drawn context menus with the OS-looking menu: `NSMenu`
// on macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent).
// The flyout always opens in a native popup surface, whatever
// `nativePopupLayers` says.
nativeContextMenu: Boolean = false,
// Hide this window from the OS taskbar/Dock while it stays visible and
// focusable (on Linux effective on X11/XWayland only).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public fun NucleusApplicationScope.MaterialDecoratedWindow(
nativePopupLayers: Boolean = false,
// Replace Compose-drawn context menus with the OS-looking menu: `NSMenu`
// on macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent).
// The flyout always opens in a native popup surface, whatever
// `nativePopupLayers` says.
nativeContextMenu: Boolean = false,
// Hide this window from the OS taskbar/Dock while it stays visible and
// focusable (on Linux effective on X11/XWayland only).
Expand Down
8 changes: 8 additions & 0 deletions decorated-window-tao/api/decorated-window-tao.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
public final class androidx/compose/ui/scene/TaoComposeSceneContextAccess {
public static fun localComposeSceneContext ()Landroidx/compose/runtime/ProvidableCompositionLocal;
}

public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBarKt {
public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBarKt;
public fun <init> ()V
Expand Down Expand Up @@ -323,6 +327,10 @@ public final class dev/nucleusframework/window/tao/MetalTestTextureProducer$Comp
public final fun create (II)Ldev/nucleusframework/window/tao/MetalTestTextureProducer;
}

public final class dev/nucleusframework/window/tao/NativePopupLayersKt {
public static final fun NativePopupLayers (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
}

public final class dev/nucleusframework/window/tao/NativeViewKt {
public static final fun NativeView-hGBTI10 (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;FLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
}
Expand Down
5 changes: 5 additions & 0 deletions decorated-window-tao/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ dependencies {
testImplementation(kotlin("test"))
// Skiko native runtime for the opt-in real-window smoke test
testImplementation(compose.desktop.currentOs)
// The Material 3 AlertDialog the headful appearance film compares against nucleus-demo
testImplementation(libs.compose.material3)
}

java {
Expand Down Expand Up @@ -168,6 +170,9 @@ val taoHeadfulTest by tasks.registering(JavaExec::class) {
System.getProperty("nucleus.tao.headful.monkeySeed")?.let {
systemProperty("nucleus.tao.headful.monkeySeed", it)
}
System.getProperties().stringPropertyNames().filter { it.startsWith("nucleus.dialog.appearance.") }.forEach {
systemProperty(it, System.getProperty(it))
}
System.getProperty("nucleus.issue576.samples")?.let {
systemProperty("nucleus.issue576.samples", it)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package androidx.compose.ui.scene;

import androidx.compose.runtime.ProvidableCompositionLocal;

/**
* Friend-package accessor for Compose's {@code LocalComposeSceneContext}, the
* composition local {@code Popup} / {@code Dialog} read to decide which
* {@link ComposeSceneContext} creates their layer. It is declared
* {@code internal} in the Kotlin module {@code compose-ui} and therefore
* unreachable from another Kotlin module — but Java does not honour Kotlin's
* {@code internal} visibility, and the getter of a top-level property is not
* name-mangled, so a Java file in the same package can call it directly.
*
* <p>No reflection: this is a static call that compiles cleanly under GraalVM
* native-image with zero reachability metadata.
*/
public final class TaoComposeSceneContextAccess {
private TaoComposeSceneContextAccess() {
}

/**
* Returns Compose's {@code LocalComposeSceneContext}.
*
* @return the composition local a scene provides for its own
* {@link ComposeSceneContext}; its current value may be
* {@code null} outside any scene
*/
public static ProvidableCompositionLocal<ComposeSceneContext> localComposeSceneContext() {
return ComposeSceneContext_skikoKt.getLocalComposeSceneContext();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,10 @@ internal fun ApplicationScope.openDecoratedWindow(
fullyTransparent = transparent,
)
}
// For NativePopupLayers { }: null when every popup is native already.
// Remembered so the static local keeps one value per window.
val nativePopupLayerFactory =
remember { if (host.nativePopupLayers) null else host.nativePopupLayerFactory() }
CompositionLocalProvider(
LocalTitleBarInfo provides TitleBarInfo(title, icon),
LocalTaoWindow provides window,
Expand All @@ -509,6 +513,7 @@ internal fun ApplicationScope.openDecoratedWindow(
dev.nucleusframework.window.tao.scene.LocalTaoMetalTextureHost
provides host.metalTextureHost(),
LocalTaoNativeViewHost provides host.nativeViewHost(),
LocalTaoNativePopupLayerFactory provides nativePopupLayerFactory,
LocalTaoCompositionLocalContextBridge provides host::setSceneCompositionLocalContext,
) {
// Re-centre the native AppKit traffic-lights whenever the
Expand Down Expand Up @@ -726,13 +731,18 @@ private fun ApplicationScope.openDecoratedWindowLinux(
fullyTransparent = transparent,
)
}
// For NativePopupLayers { }: null when every popup is native already.
// Remembered so the static local keeps one value per window.
val nativePopupLayerFactory =
remember { if (host.nativePopupLayers) null else host.nativePopupLayerFactory() }
CompositionLocalProvider(
LocalTitleBarInfo provides TitleBarInfo(title, icon),
LocalTaoWindow provides window,
LocalRequestedTitleBarHeight provides titleBarHeightState,
LocalWindowClearColorLayers provides clearColorLayers,
LocalFullscreenTitleBarHolder provides fullscreenHolder,
LocalTaoNativeViewHost provides host.nativeViewHost(),
LocalTaoNativePopupLayerFactory provides nativePopupLayerFactory,
LocalTaoCompositionLocalContextBridge provides host::setSceneCompositionLocalContext,
// Read as state: a Wayland hide/show rebuilds the EGL + Skia
// context pair, and TextureView imports must follow it.
Expand Down Expand Up @@ -1160,6 +1170,10 @@ private fun ApplicationScope.openDecoratedWindowWindows(
fullyTransparent = transparent,
)
}
// For NativePopupLayers { }: null when every popup is native already.
// Remembered so the static local keeps one value per window.
val nativePopupLayerFactory =
remember { if (host.nativePopupLayers) null else host.nativePopupLayerFactory() }
CompositionLocalProvider(
LocalTitleBarInfo provides TitleBarInfo(title, icon),
LocalTaoWindow provides window,
Expand All @@ -1169,6 +1183,7 @@ private fun ApplicationScope.openDecoratedWindowWindows(
LocalBackdropComposeTint provides host.backdropTintArgbState,
LocalFullscreenTitleBarHolder provides fullscreenHolder,
LocalTaoNativeViewHost provides host.nativeViewHost(),
LocalTaoNativePopupLayerFactory provides nativePopupLayerFactory,
LocalTaoCompositionLocalContextBridge provides host::setSceneCompositionLocalContext,
dev.nucleusframework.window.tao.popup.LocalTaoPopupHostWindows
provides host.popupHost(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
@file:OptIn(InternalComposeUiApi::class)

package dev.nucleusframework.window.tao

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.scene.ComposeSceneContext
import androidx.compose.ui.scene.ComposeSceneLayer
import androidx.compose.ui.scene.TaoComposeSceneContextAccess
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import dev.nucleusframework.window.tao.scene.TaoPopupLayerFactory

/**
* The window's native popup layer factory, for [NativePopupLayers]. Provided
* by every Tao window that draws its own popups in-scene; `null` when the
* window already runs on native popup layers (nothing to opt into) or has no
* native popup pipeline.
*/
internal val LocalTaoNativePopupLayerFactory: ProvidableCompositionLocal<TaoPopupLayerFactory?> =
staticCompositionLocalOf { null }

/**
* Materialises every Compose `Popup` / `DropdownMenu` / `Tooltip` opened
* directly inside [content] as a native popup surface — an `NSPanel` on
* macOS, a transparent `WS_POPUP` HWND on Windows, a Tao popup window on
* Linux — exactly as `DecoratedWindow(nativePopupLayers = true)` does for the
* whole window, but for this subtree only. Popups opened elsewhere in the
* window keep drawing inside its render target.
*
* This is what an OS-looking flyout needs: it must be able to leave the
* window like the platform's own menus, and it must not depend on what the
* application chose for its other popups. Popups opened from *inside* a
* native surface (a submenu) already live in that surface's own scene and
* need no further opt-in.
*
* A no-op when the window already runs on native popup layers, when it has
* no native popup pipeline (not attached yet, native bridge missing), or
* outside a Tao window: [content] then composes unchanged.
*/
@Suppress("FunctionNaming")
@Composable
public fun NativePopupLayers(content: @Composable () -> Unit) {
val layerFactory = LocalTaoNativePopupLayerFactory.current
val local = TaoComposeSceneContextAccess.localComposeSceneContext()
// Platform type: the scene provides it for its own composition, so it is
// only null outside any scene (the application root).
val sceneContext: ComposeSceneContext? = local.current
if (layerFactory == null || sceneContext == null) {
content()
return
}
val nativeLayerContext =
remember(sceneContext, layerFactory) { NativeLayerSceneContext(sceneContext, layerFactory) }
CompositionLocalProvider(local provides nativeLayerContext, content = content)
}

/**
* The window scene's own context with one difference: layers come out of the
* window's native popup pipeline instead of the scene's canvas. Everything
* else — the platform context above all — is the scene's, so nothing that
* reads the context sees a different window.
*/
private class NativeLayerSceneContext(
sceneContext: ComposeSceneContext,
private val layerFactory: TaoPopupLayerFactory,
) : ComposeSceneContext by sceneContext {
override fun createLayer(
density: Density,
layoutDirection: LayoutDirection,
focusable: Boolean,
consumePointerInputOutside: Boolean,
): ComposeSceneLayer = layerFactory(density, layoutDirection, focusable, consumePointerInputOutside)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Logger
import kotlin.math.roundToInt

/**
* Phase 2 handle to a window owned by the Tao event loop.
Expand Down Expand Up @@ -928,6 +929,52 @@ public class TaoWindow internal constructor(
NativeTaoBridge.nativeSetOuterPosition(handle, x, y)
}

/**
* Linux native Wayland only, for a popup overlay (`openWindow(popupOf = …)`):
* anchors the popup's content at a point of the parent's content area
* through GDK's `move_to_rect`, so it maps as an `xdg_popup` the compositor
* keeps on screen — flipped above the point when there is no room below,
* slid along an edge — instead of a `wl_subsurface` the compositor cannot
* constrain. The shadow margins are the transparent border the surface
* carries around its content; the compositor constrains the content, not
* the margin. The surface size is applied here too, because GDK builds the
* positioner from the window's current geometry — a popup still sized 1×1
* asks the compositor to constrain a 1×1 rectangle and is never flipped.
* GDK positions a popup once, at map: call before [show], and never
* [setOuterPosition] or [setInnerSize] afterwards (either one re-maps it as
* a plain subsurface).
*/
internal fun anchorPopupInParent(
contentXDp: Double,
contentYDp: Double,
widthDp: Double,
heightDp: Double,
shadowLeftDp: Int,
shadowTopDp: Int,
shadowRightDp: Int,
shadowBottomDp: Int,
) {
var x = contentXDp
var y = contentYDp
// Same content-area → parent-surface conversion as setOuterPosition.
if (isPopup && popupParentHandle != 0L && parentIsNativeWayland()) {
val packed = NativeTaoBridge.nativeLinuxContentOrigin(popupParentHandle)
x += (packed shr 32).toInt()
y += packed.toInt()
}
NativeTaoBridge.nativeLinuxPopupAnchor(
handle,
x.roundToInt(),
y.roundToInt(),
widthDp.roundToInt(),
heightDp.roundToInt(),
shadowLeftDp,
shadowTopDp,
shadowRightDp,
shadowBottomDp,
)
}

/**
* [setOuterPosition] in physical screen pixels — the coordinate space
* [outerBoundsPx] reports in, so a caller that computes a target from live
Expand Down Expand Up @@ -956,7 +1003,7 @@ public class TaoWindow internal constructor(
}

/** `true` when the popup parent is a native Wayland surface (kind == 2). */
private fun parentIsNativeWayland(): Boolean {
internal fun parentIsNativeWayland(): Boolean {
if (Platform.Current != Platform.Linux || !NativeTaoBridge.isLoaded) return false
val handles = NativeTaoBridge.nativeLinuxHandles(popupParentHandle) ?: return false
return handles.isNotEmpty() && handles[0] == WAYLAND_HANDLE_KIND
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,24 @@ internal object NativeTaoBridge {
y: Double,
)

/**
* Linux only: anchors a popup overlay (`popupOf`) at a logical point of
* its parent window through GDK's `move_to_rect`, so GDK maps it as a
* compositor-positioned `xdg_popup` — see [TaoWindow.anchorPopupInParent].
*/
@JvmStatic
external fun nativeLinuxPopupAnchor(
handle: Long,
x: Int,
y: Int,
width: Int,
height: Int,
shadowLeft: Int,
shadowTop: Int,
shadowRight: Int,
shadowBottom: Int,
)

@JvmStatic
external fun nativeIsFullscreen(handle: Long): Boolean

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ internal object NativeTaoMacOsDecoBridge {
@JvmStatic
external fun nativeGetWindowRect(nsView: Long): LongArray?

/**
* Returns the view's own rect on screen as `[x, y, width, height]` in
* physical pixels with a top-left origin — same convention as
* [nativeGetWindowRect] and [nativeGetMonitors].
*
* This is the origin window-rooted Compose coordinates are relative to,
* which is *not* the window frame origin when the window has a native
* title bar. Used by the popup screen clamp
* ([dev.nucleusframework.window.tao.popup.popupScreenClampOffset], #569) to
* turn a popup's window-rooted frame into screen coordinates. Returns
* `null` if the view is not attached to an NSWindow.
*/
@JvmStatic
external fun nativeGetContentRect(nsView: Long): LongArray?

/**
* Returns the primary screen's `visibleFrame` (full screen minus menu bar
* and Dock) as `[x, y, width, height]` in physical pixels with a top-left
Expand Down
Loading
Loading