diff --git a/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedWindow.kt b/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedWindow.kt index 112400a4f..fa791f844 100644 --- a/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedWindow.kt +++ b/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedWindow.kt @@ -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). @@ -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() @@ -94,6 +107,7 @@ public fun NucleusApplicationScope.JewelDecoratedWindow( forceX11 = forceX11, undecorated = undecorated, popupFor = popupFor, + nativePopupLayers = nativePopupLayers, nativeContextMenu = nativeContextMenu, hiddenFromDock = hiddenFromDock, minimumSize = minimumSize, diff --git a/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedWindow.kt b/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedWindow.kt index a2bd80a38..582d88cc3 100644 --- a/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedWindow.kt +++ b/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedWindow.kt @@ -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). diff --git a/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedWindow.kt b/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedWindow.kt index b404005f6..97f45a4a7 100644 --- a/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedWindow.kt +++ b/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedWindow.kt @@ -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). diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index bece0eaa3..02103417b 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -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 ()V @@ -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 } diff --git a/decorated-window-tao/build.gradle.kts b/decorated-window-tao/build.gradle.kts index b2fe69864..fc4cca49a 100644 --- a/decorated-window-tao/build.gradle.kts +++ b/decorated-window-tao/build.gradle.kts @@ -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 { @@ -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) } diff --git a/decorated-window-tao/src/main/java/androidx/compose/ui/scene/TaoComposeSceneContextAccess.java b/decorated-window-tao/src/main/java/androidx/compose/ui/scene/TaoComposeSceneContextAccess.java new file mode 100644 index 000000000..071933455 --- /dev/null +++ b/decorated-window-tao/src/main/java/androidx/compose/ui/scene/TaoComposeSceneContextAccess.java @@ -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. + * + *

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 localComposeSceneContext() { + return ComposeSceneContext_skikoKt.getLocalComposeSceneContext(); + } +} 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 f0f5aa6bf..237ebf0ea 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 @@ -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, @@ -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 @@ -726,6 +731,10 @@ 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, @@ -733,6 +742,7 @@ private fun ApplicationScope.openDecoratedWindowLinux( 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. @@ -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, @@ -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(), diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativePopupLayers.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativePopupLayers.kt new file mode 100644 index 000000000..f5159329c --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativePopupLayers.kt @@ -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 = + 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) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index 3c2e57938..bb8ac2435 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -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. @@ -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 @@ -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 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 e850bea68..d64b08f11 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 @@ -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 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsDecoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsDecoBridge.kt index 53a10c7cc..dc8fb8449 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsDecoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsDecoBridge.kt @@ -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 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflate.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflate.kt new file mode 100644 index 000000000..d83d72641 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflate.kt @@ -0,0 +1,43 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntRect +import kotlin.math.ceil + +/** + * Margin, in dp, that a native popup layer's surface extends past + * `boundsInWindow` on every side, so that what Compose draws outside the + * layout rectangle is not clipped at the surface edge. + * + * `boundsInWindow` is the popup's *layout* rectangle. What Compose draws is + * routinely larger: a Material dialog or menu carries an elevation shadow + * (6 dp for an `AlertDialog`, 8 dp for a `DropdownMenu`, whose blur and + * offset reach roughly twice that), and `Dialog.skiko.kt` animates the dialog + * in from 10 dp below, scaled down and faded. An in-scene layer overflows into + * the window canvas for free; a separate OS surface clips at its own edge. + * + * The margin is a constant rather than a measurement. Compose Desktop's + * `WindowComposeSceneLayer` measures the drawn bounds with a picture + * recorder's R-tree, but since Compose 1.12 a scene draws through skiko + * `RenderNode`s — a single `drawDrawable` op whose bounds are unbounded — so + * that measurement only ever reports the whole canvas. 32 dp covers every + * Material elevation and the appearance animation with room to spare, and + * costs a constant fraction of the surface. + */ +internal const val POPUP_DRAW_MARGIN_DP: Float = 32f + +/** [POPUP_DRAW_MARGIN_DP] in physical pixels at [density] (px per dp). */ +internal fun popupDrawMarginPx(density: Float): Int = ceil(POPUP_DRAW_MARGIN_DP * density.coerceAtLeast(1f)).toInt() + +/** [bounds] inflated by [popupDrawMarginPx]: the rectangle the layer's surface must cover. */ +internal fun popupDrawBounds( + bounds: IntRect, + density: Float, +): IntRect { + val margin = popupDrawMarginPx(density) + return IntRect( + left = bounds.left - margin, + top = bounds.top - margin, + right = bounds.right + margin, + bottom = bounds.bottom + margin, + ) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClamp.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClamp.kt new file mode 100644 index 000000000..137569207 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClamp.kt @@ -0,0 +1,70 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect + +/** + * Offset to add to [frameInParentPx] so the popup lands fully inside the work + * area of the display it belongs to, in the owner-window coordinate space the + * native `setFrame` calls take. + * + * Clamp, not flip: a popup pushed past an edge slides back in rather than + * re-opening on the other side of its anchor — the behaviour of most native + * menus, and the only one reachable without intercepting + * `PopupPositionProvider.calculatePosition` (which receives the anchor in + * window coordinates and cannot be told about a screen origin; see #569). + * + * Returns [IntOffset.Zero] — i.e. exactly the pre-#569 behaviour — whenever + * the platform cannot resolve the geometry ([geometry] is `null`, as on + * Wayland where popups are parent-relative subsurfaces with no global + * position), or the frame has no area yet. + */ +internal fun popupScreenClampOffset( + frameInParentPx: IntRect, + geometry: PopupScreenGeometry?, +): IntOffset { + if (geometry == null) return IntOffset.Zero + val width = frameInParentPx.width + val height = frameInParentPx.height + if (width <= 0 || height <= 0) return IntOffset.Zero + + val origin = geometry.parentContentOriginPx + val left = origin.x + frameInParentPx.left + val top = origin.y + frameInParentPx.top + val onScreen = IntRect(left = left, top = top, right = left + width, bottom = top + height) + val work = pickWorkArea(onScreen, origin, geometry.workAreasPx) ?: return IntOffset.Zero + + // `coerceAtMost` before `coerceAtLeast`: a popup taller or wider than the + // work area keeps its top-left visible (where a menu's first items and a + // tooltip's text are) instead of its bottom-right. + val clampedLeft = onScreen.left.coerceAtMost(work.right - width).coerceAtLeast(work.left) + val clampedTop = onScreen.top.coerceAtMost(work.bottom - height).coerceAtLeast(work.top) + return IntOffset(clampedLeft - onScreen.left, clampedTop - onScreen.top) +} + +/** + * The display [frame] belongs to: the one it overlaps most. A frame that + * overlaps nothing — the very case the clamp exists for — is attributed to the + * display hosting the owner window's content origin, so the popup slides back + * onto the display the user is looking at instead of the first one enumerated. + */ +private fun pickWorkArea( + frame: IntRect, + parentOrigin: IntOffset, + areas: List, +): IntRect? { + val usable = areas.filter { it.width > 0 && it.height > 0 } + if (usable.size <= 1) return usable.firstOrNull() + val best = usable.maxBy { overlapArea(it, frame) } + if (overlapArea(best, frame) > 0L) return best + return usable.firstOrNull { it.contains(parentOrigin) } ?: usable.first() +} + +private fun overlapArea( + a: IntRect, + b: IntRect, +): Long { + val width = (minOf(a.right, b.right) - maxOf(a.left, b.left)).coerceAtLeast(0) + val height = (minOf(a.bottom, b.bottom) - maxOf(a.top, b.top)).coerceAtLeast(0) + return width.toLong() * height.toLong() +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenGeometry.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenGeometry.kt new file mode 100644 index 000000000..87d38731a --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenGeometry.kt @@ -0,0 +1,47 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect + +/** + * Screen geometry a native popup layer needs to place itself against the + * *display* rather than against its owner window (#569). + * + * Compose decides a popup's position entirely in window-rooted coordinates. + * `Popup.skiko.kt` flips and clips inside `[0, containerSize]`, where + * `containerSize` is whatever the layer's own composition reports through + * `LocalWindowInfo` — the layers answer with the work area, so a popup lays out + * at full size and flips against a screen-sized box. But that box is *rooted at + * the window's content top-left*: a virtual screen, correct only while the + * content origin happens to coincide with the work-area origin (roughly: + * maximized on the primary display). Everywhere else a `DropdownMenu` near the + * real screen edge lands offscreen. + * + * [popupScreenClampOffset] closes that gap at the single choke point where each + * layer pushes its native frame, using the two pieces of information the + * platform has but Compose never sees: where the owner's content sits on + * screen, and where the displays' work areas are. + * + * The window-rooted box has one consequence the clamp cannot undo: a popup can + * never be placed *above or left of* the owner's content origin, because + * `clipPosition` coerces the position into `[0, …]` there. Fixing that means + * intercepting `PopupPositionProvider.calculatePosition` (which receives the + * anchor in window coordinates), i.e. owning the `Popup` composable the way + * Jewel's `LocalPopupRenderer` does — see #569. + */ +internal class PopupScreenGeometry( + /** + * Owner window's **content** origin in global screen physical pixels, + * top-left origin — the same space [workAreasPx] is expressed in. This is + * the origin the layers' window-rooted frames are implicitly relative to. + */ + val parentContentOriginPx: IntOffset, + /** + * Work area (display minus taskbar / menu bar / dock / panels) of every + * attached display, in global screen physical pixels. A list rather than + * the owner's display alone: a popup anchored near the edge of a window + * that straddles two displays belongs to the display *it* lands on, which + * is not necessarily the one hosting the window's centre. + */ + val workAreasPx: List, +) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistry.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistry.kt new file mode 100644 index 000000000..cd9a3a69d --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistry.kt @@ -0,0 +1,113 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import org.jetbrains.skia.BlendMode +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Rect + +/** + * The dialog scrims of a host window's native popup layers, in stacking order. + * + * A Compose `Dialog` never paints its own scrim: `Dialog.skiko.kt` writes + * `ComposeSceneLayer.scrimColor` and leaves the painting to whoever renders + * *underneath* the layer. Compose Desktop's `ComposeContainer.onRenderOverlay` + * paints every layer's scrim over the main window after the main scene, and + * `WindowComposeSceneLayer` paints the scrims of the layers above it into its + * own window, so a popup open under a dialog is dimmed too. With native popup + * layers each layer is a separate OS surface, so the same two passes are + * needed here: [paintAll] from the owner window's scene, [paintAbove] from + * each layer's scene. + * + * Registration order is stacking order: Compose creates layers bottom-up, and + * a layer registers itself in its constructor. + * + * Threading: main / event-loop thread only, like the layers themselves. Colors + * are read through a provider at paint time so a scrim set after registration + * (which is always: `scrimColor` is written during the dialog's composition) + * is picked up without re-registering. + */ +internal class PopupScrimRegistry( + /** + * Invoked when a layer's scrim changed. The host repaints the owner window + * — and marks its scene visually dirty: a scrim fade alone raises no layout + * or draw invalidation in that scene, and a host that skips presenting + * clean frames would otherwise never show it. + */ + private val onChanged: () -> Unit, +) { + private val scrims = LinkedHashMap Color?>() + + /** A layer's `scrimColor` changed; see [onChanged]. */ + fun notifyChanged() = onChanged() + + /** Adds [token]'s layer on top of the stack. Re-registering moves it to the top. */ + fun register( + token: Any, + color: () -> Color?, + ) { + scrims.remove(token) + scrims[token] = color + } + + fun unregister(token: Any) { + scrims.remove(token) + } + + /** The scrims of every registered layer, bottom-up. */ + fun all(): List = scrims.values.mapNotNull { it() } + + /** The scrims of the layers stacked above [token], bottom-up. */ + fun above(token: Any): List { + val out = ArrayList() + var seen = false + for ((key, color) in scrims) { + if (seen) color()?.let(out::add) + if (key == token) seen = true + } + return out + } + + /** + * Paints every scrim over [rect] — the owner window's whole surface. + * [transparent] selects the blend mode exactly as Compose's + * `getDialogScrimBlendMode` does: a per-pixel-alpha window must only darken + * what it drew (`SrcAtop`), an opaque one darkens everything (`SrcOver`). + */ + fun paintAll( + canvas: Canvas, + rect: Rect, + transparent: Boolean, + ) = paint(canvas, rect, transparent, all()) + + /** + * Paints the scrims of the layers above [token] over [rect] — the visible + * part of that layer's own surface. Popup surfaces are always per-pixel + * transparent, so the blend is `SrcAtop`. + */ + fun paintAbove( + token: Any, + canvas: Canvas, + rect: Rect, + ) = paint(canvas, rect, transparent = true, above(token)) + + private fun paint( + canvas: Canvas, + rect: Rect, + transparent: Boolean, + colors: List, + ) { + if (colors.isEmpty()) return + val paint = Paint() + try { + paint.blendMode = if (transparent) BlendMode.SRC_ATOP else BlendMode.SRC_OVER + for (color in colors) { + paint.color = color.toArgb() + canvas.drawRect(rect, paint) + } + } finally { + paint.close() + } + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt new file mode 100644 index 000000000..1d5d38d2d --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt @@ -0,0 +1,91 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import java.util.concurrent.atomic.AtomicReference + +/** + * One popup layer's positioning decision, as pushed to the platform. + * + * Carries both sides of the #569 split — what Compose decided + * ([boundsInWindowPx], window-rooted) and where the popup actually went + * ([frameOnScreenPx], global screen physical pixels) — so a test can assert + * not only that the popup is on screen but that the clamp is what put it + * there. + */ +internal class PopupFrameRecord( + /** `boundsInWindow` as Compose computed it, unclamped. Window-rooted physical px. */ + val boundsInWindowPx: IntRect, + /** + * The native surface's frame, in global screen physical px. Inflated past + * [contentOnScreenPx] by whatever the popup draws outside its layout + * bounds (shadows, the dialog appearance animation) — see + * [PopupDrawInflate]. + */ + val frameOnScreenPx: IntRect, + /** Where [boundsInWindowPx] landed, in global screen physical px: the popup as the user sees it. */ + val contentOnScreenPx: IntRect, + /** [popupScreenClampOffset]'s verdict — [IntOffset.Zero] when nothing had to move. */ + val clampOffsetPx: IntOffset, + /** + * The layer's native popup handle: a `PopupState*` on Windows, an + * `NSPanel*` on macOS, a [dev.nucleusframework.window.tao.TaoWindow] handle + * on Linux. Opaque here; a platform-specific test dereferences it to read + * the real on-screen rect back from the OS. + */ + val panelHandle: Long, +) + +/** + * Last frame every native popup layer pushed — the seam the headful suite + * asserts the #569 placement contract through ("a popup never lands outside + * the work area of the display it belongs to"). + * + * A native popup layer is not reachable from a test: Compose creates it inside + * the scene's render pass, and it owns a `WS_POPUP` HWND / `NSPanel` / + * override-redirect window nobody publishes. Recording the pushed frame at the + * choke point is the smallest seam that makes the real placement observable — + * and the *only* one that can tell an offscreen popup from a popup that just + * happened to be anchored somewhere safe, since `boundsInWindow` is + * deliberately left unclamped. + * + * Not reactive Compose state (unlike [dev.nucleusframework.window.tao.TaoDnDDiagnostics]): + * these writes happen on the popup's frame path, where a snapshot write would + * invalidate the very composition producing them. + */ +internal object TaoPopupDiagnostics { + private val last = AtomicReference(null) + + /** + * Most recently positioned popup layer. `null` until one pushes a real + * frame; never cleared by the layers, so a test can read it after the + * popup was dismissed. + */ + val lastFrame: PopupFrameRecord? get() = last.get() + + /** Frames pushed since the last [reset], clamped or not. */ + @Volatile + var frameCount: Int = 0 + private set + + fun record(record: PopupFrameRecord) { + last.set(record) + frameCount++ + } + + /** + * Whether the most recently placed Linux popup layer let the *compositor* + * position it (an `xdg_popup`, native Wayland) rather than placing itself. + * `null` until one is placed. The Wayland half of the #569 contract: there + * is no screen geometry to assert against there, so the placement decision + * is what a test can hold on to. + */ + @Volatile + var lastCompositorPlaced: Boolean? = null + + fun reset() { + last.set(null) + frameCount = 0 + lastCompositorPlaced = null + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHost.kt index cc184a7a9..e520721e6 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHost.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.WindowExceptionHandler @@ -34,6 +35,14 @@ internal interface TaoPopupHost { */ val parentWindowSize: IntSize + /** + * The owner window's live `WindowInfo`. Its `containerSize` is snapshot + * state, so a dialog that centres itself in it (`Dialog.skiko.kt` reads + * `LocalWindowInfo.current.containerSize`) re-measures when the window is + * resized — [parentWindowSize] is a plain read and would leave it frozen. + */ + val parentWindowInfo: WindowInfo + /** * Visible-frame size (screen minus menu bar + dock) of the NSScreen * hosting the owner window, in **physical pixels**. Used by popup @@ -51,6 +60,21 @@ internal interface TaoPopupHost { */ val workAreaSize: IntSize get() = parentWindowSize + /** + * Where the owner window sits on screen, and where the displays' work + * areas are — the origin [workAreaSize] deliberately throws away. + * + * [workAreaSize] gives the popup room to lay out at full size, but Compose + * then flips and clips inside that size *rooted at the window*, so the + * decision is made against a virtual screen rather than the real one. + * Layers use this to clamp their native frame back into the display's work + * area at the point they push it. `null` when the platform cannot resolve + * it (early init, no screen), which restores the unclamped behaviour. + * + * Read on every frame push; implementations must stay cheap. + */ + val popupScreenGeometry: PopupScreenGeometry? get() = null + /** Coroutine context to feed inner scenes (parent context + frame clock + flushing dispatcher). */ val sceneCoroutineContext: CoroutineContext @@ -81,6 +105,14 @@ internal interface TaoPopupHost { */ val isOwnerWindowTransparent: Boolean get() = false + /** + * The dialog scrims of this host's layers. A layer registers its + * `scrimColor` here for its whole lifetime; the host paints them all over + * the owner window's scene, and every layer paints the ones above it into + * its own surface — see [PopupScrimRegistry]. + */ + val popupScrims: PopupScrimRegistry + fun requestRedraw() /** diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt index f6497d20f..dd1020a88 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt @@ -3,6 +3,7 @@ package dev.nucleusframework.window.tao.popup import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.WindowExceptionHandler @@ -39,6 +40,9 @@ internal interface TaoPopupHostLinux { /** Host window's content size in physical pixels. */ val parentWindowSize: IntSize + /** The owner window's live `WindowInfo` — see [TaoPopupHost.parentWindowInfo]. */ + val parentWindowInfo: WindowInfo + /** * Screen work area in physical pixels. Used as the inner scene's * layout size so a tall popup (DropdownMenu, expanded Tooltip) in a @@ -59,6 +63,17 @@ internal interface TaoPopupHostLinux { */ val parentScreenOriginPx: IntOffset + /** + * [parentScreenOriginPx] paired with every display's work area, so a layer + * can clamp its native frame into the real screen instead of the + * window-rooted virtual one Compose positions against. See + * [TaoPopupHost.popupScreenGeometry]. + * + * `null` on Wayland: a popup there is a `wl_subsurface` placed relative to + * the parent surface, and no global position exists to clamp against. + */ + val popupScreenGeometry: PopupScreenGeometry? get() = null + /** Coroutine context to feed inner scenes. */ val sceneCoroutineContext: CoroutineContext @@ -73,6 +88,9 @@ internal interface TaoPopupHostLinux { */ val coordinateOffset: IntOffset get() = IntOffset.Zero + /** The dialog scrims of this host's layers — see [TaoPopupHost.popupScrims]. */ + val popupScrims: PopupScrimRegistry + fun requestRedraw() /** @@ -131,4 +149,17 @@ internal interface TaoPopupHostLinux { ) fun unregisterOutsidePressListener(token: Any) + + /** + * Claims the parent's compositor-positioned popup for [token]. On native + * Wayland a popup layer that gets it maps as an `xdg_popup` the compositor + * keeps on screen ([TaoWindow.anchorPopupInParent]); an `xdg_popup` must be + * its parent's topmost popup and GDK refuses to map a second one, so only + * one layer at a time may take that path — the others stay subsurfaces. + * Returns `false` while another layer holds it. + */ + fun acquireCompositorPopup(token: Any): Boolean + + /** Releases [acquireCompositorPopup]'s claim; a no-op for a token that never held it. */ + fun releaseCompositorPopup(token: Any) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostWindows.kt index c45b40cec..9f9f2bae1 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostWindows.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.WindowExceptionHandler @@ -32,6 +33,9 @@ internal interface TaoPopupHostWindows { /** Host window's content size in physical pixels. */ val parentWindowSize: IntSize + /** The owner window's live `WindowInfo` — see [TaoPopupHost.parentWindowInfo]. */ + val parentWindowInfo: WindowInfo + /** * Screen work area in physical pixels. Used as the inner scene's * layout size so a tall popup (DropdownMenu, expanded Tooltip) in a @@ -42,6 +46,14 @@ internal interface TaoPopupHostWindows { */ val workAreaSize: IntSize get() = parentWindowSize + /** + * Owner client origin on screen + every display's work area, so a layer + * can clamp its native frame into the real screen instead of the + * window-rooted virtual one Compose positions against. See + * [TaoPopupHost.popupScreenGeometry]. + */ + val popupScreenGeometry: PopupScreenGeometry? get() = null + /** Coroutine context to feed inner scenes. */ val sceneCoroutineContext: CoroutineContext @@ -80,6 +92,9 @@ internal interface TaoPopupHostWindows { */ val hostDirectContext: DirectContext + /** The dialog scrims of this host's layers — see [TaoPopupHost.popupScrims]. */ + val popupScrims: PopupScrimRegistry + fun requestRedraw() /** diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt index 477eb8928..fcec41cde 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.CompositionLocalContext import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -12,6 +14,7 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -36,6 +39,7 @@ import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.catchExceptions import dev.nucleusframework.window.tao.scene.recordSceneToPicture import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Rect /** * `ComposeSceneLayer` implementation used by macOS overlay scenes to back @@ -64,13 +68,16 @@ import org.jetbrains.skia.DirectContext * what makes `MaterialTheme.colorScheme` etc. flow into the popup * content automatically. * - * Phase 3 deliberately omits: - * - `setOutsidePointerEventListener` — outside-click dismissal lands - * in Phase 4 (NSEvent local monitor on the parent window). - * - `setKeyEventListener` — key forwarding lands in Phase 4 too. - * - `scrimColor` — would need a third surface (full-window-sized - * overlay between main scene and popup). Not relevant for context - * menus / dropdowns. + * The frame pushed to the panel is clamped into the hosting display's work + * area ([popupScreenClampOffset], #569) every time `boundsInWindow` changes. + * Unlike the Windows and Linux layers there is no re-clamp on owner move: the + * panel is an AppKit child window and rides along with the NSWindow, so a + * window dragged past a screen edge with a popup already open takes it along + * — the same thing AppKit's own menus avoid by closing on window move. + * + * `scrimColor` is not painted here: a dialog's scrim covers what lies *under* + * the layer, so the owner window's scene paints every layer's scrim and each + * layer paints the ones of the layers above it — see [PopupScrimRegistry]. * * Threading: every method must run on the macOS main thread. */ @@ -86,7 +93,7 @@ internal class TaoPopupSceneLayer( private var _layoutDirection = initialLayoutDirection private var _focusable = initialFocusable private var _bounds: IntRect = IntRect.Zero - private var _scrimColor: Color? = null + private val scrimColorState: MutableState = mutableStateOf(null) private var _compositionLocalContext: CompositionLocalContext? = null private val rendererToken: Any = Any() @@ -107,6 +114,58 @@ internal class TaoPopupSceneLayer( IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) } + /** + * Compose's box for placing this layer's content, as reported through + * `LocalWindowInfo` inside the layer's own composition (#569). + * + * Two answers, because two very different things end up in a scene layer: + * + * - A **popup** (`Popup`, `DropdownMenu`, context menu, tooltip, Jewel's + * combo-box flyout) belongs to the *display*. It gets the work area + * ([sceneLayoutSize]), so `Popup.skiko.kt` lays it out at full size and + * flips it against a screen-sized box instead of against the owner + * window — the point of native popup layers. That box is still rooted at + * the window; the origin is what the screen clamp corrects when the + * frame is pushed. + * - A **dialog** (`Dialog`, Material `AlertDialog`) belongs to its + * *window*: `Dialog.skiko.kt` places it at `containerSize.center`, and a + * window-owned dialog centred on the display would sit visibly + * off-centre — and drift further as the user moved the window. It gets + * the owner window's content size, exactly as before #569. + * + * `scrimColor` is the discriminator, and a sound one: only + * `Dialog.skiko.kt` ever writes it, from + * `DialogAppearanceController.properties` — assigned while `DialogLayout` + * composes, *before* `layer.Content { }` and so before this is read. + * `Popup.skiko.kt` never touches it. Held as snapshot state so a later + * write recomposes the content that read it. + */ + private val dialogContainerSize: IntSize + get() = + host.parentWindowInfo.containerSize.let { + IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) + } + + /** + * The rectangle the panel covers, in scene coordinates: [_bounds] inflated + * by [popupDrawBounds] so shadows and the dialog appearance animation are + * not clipped at the layout edge. The panel's interactive region stays + * [_bounds], so a click in the margin falls through to the parent window + * and reaches the outside-click monitor like any other outside click. + */ + private var drawBounds: IntRect = IntRect.Zero + + /** + * The last non-empty [_bounds]: what the native surface is sized and placed + * on. `Dialog.skiko.kt`'s disappearance swaps the layer's content for an + * empty `Layout` that only replays the recorded picture, so Compose reports + * a zero-size `boundsInWindow` at the window centre for the whole fade-out. + * An in-scene layer does not care — it draws into the window canvas — but + * this surface must keep covering where the dialog was, or the fade-out + * shows as a square of margin around a point. + */ + private var contentBounds: IntRect = IntRect.Zero + /** * Panel created at parent-window-size offscreen so the inner scene * has real layout constraints, while the user doesn't see a 1×1 @@ -173,11 +232,11 @@ internal class TaoPopupSceneLayer( /** * Inner scene at screen work-area size — see "measurement chicken- - * and-egg" in the class doc. The CAMetalLayer is sized to the popup's - * actual bounds (smaller); render writes scene content (positioned - * at 0,0 by `Popup.skiko.kt`'s `RootMeasurePolicy`) into the smaller - * surface — content fits because the popup framework lays out at - * `IntSize(widthPx, heightPx)` matching `boundsInWindow.size`. + * and-egg" in the class doc. The CAMetalLayer is sized to [drawBounds] + * (smaller); the scene is laid out in window coordinates + * ([calculateLocalPosition] is the identity) and replayed into the + * surface translated by `-drawBounds.topLeft`, the same model as the + * Windows and Linux layers. * * Custom WindowInfo with `isWindowFocused = true`. Compose's * `BasicTextField` (and other focus-aware widgets) gate the visible @@ -197,7 +256,8 @@ internal class TaoPopupSceneLayer( private val popupWindowInfo: androidx.compose.ui.platform.WindowInfo = object : androidx.compose.ui.platform.WindowInfo { override val isWindowFocused: Boolean = true - override val containerSize: IntSize get() = sceneLayoutSize + override val containerSize: IntSize + get() = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize } private val sceneBundle: TaoSceneBundle = @@ -226,10 +286,41 @@ internal class TaoPopupSceneLayer( ).apply { // Report through the owner window's channel — see [TaoPopupHost.exceptionHandler]. exceptionHandler = host.exceptionHandler + // Dim this popup under the dialogs stacked above it. The scene draws + // at the panel's own top-left, so the visible surface is the origin + // plus the drawable size. + renderOverlay = { canvas -> + host.popupScrims.paintAbove( + rendererToken, + canvas, + Rect.makeXYWH( + drawBounds.left.toFloat(), + drawBounds.top.toFloat(), + widthPx.toFloat(), + heightPx.toFloat(), + ), + ) + } } private val innerScene: ComposeScene get() = sceneBundle.scene + /** + * Keeps the inner scene's size on the box the layer's content lays out in + * (#569). A dialog's root `Layout` fills the scene's constraints, and + * `Dialog.skiko.kt` puts its appearance animation's `GraphicsLayer` on + * that very Layout — so the scale pivots around the *scene's* centre. In + * the window's own scene that box is the window, whose centre is the + * dialog's; a work-area-sized scene would make the dialog slide towards + * the display's centre while it scales in. Popups keep the work area so a + * tall menu can lay out at full height. Re-checked every frame: the window + * may have been resized since. + */ + private fun syncSceneSize() { + val want = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize + if (innerScene.size != want) innerScene.size = want + } + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null @@ -264,7 +355,7 @@ internal class TaoPopupSceneLayer( } innerScene.sendPointerEvent( eventType = eventType, - position = Offset(x, y), + position = scenePosition(x, y), type = PointerType.Mouse, button = pointerButton, ) @@ -277,9 +368,10 @@ internal class TaoPopupSceneLayer( dy: Float, precise: Boolean, ) = host.exceptionHandler.catchExceptions { + val pos = scenePosition(x, y) innerScene.dispatchAwtShapedScroll( - x, - y, + pos.x, + pos.y, appKitWheelToAwtScrollEvent(dx, dy, precise, scale), ) } @@ -319,7 +411,9 @@ internal class TaoPopupSceneLayer( init { NativeMetalBridge.nativeResize(attachmentHandle, widthPx, heightPx, scale) PopupNativeBridge.nativeSetEventCallback(panelHandle, PopupEventCallback()) + PopupNativeBridge.nativeSetRegionHitTestEnabled(panelHandle, true) host.registerRenderer(rendererToken) { recordSurface() } + host.popupScrims.register(rendererToken) { scrimColorState.value } } // ── ComposeSceneLayer surface ────────────────────────────────────── @@ -342,32 +436,82 @@ internal class TaoPopupSceneLayer( get() = _bounds set(value) { _bounds = value - // `value` is in the parent scene's coordinate system - // (top-left origin). For host-window-rooted scenes the offset - // is zero; for `NativeView`'s overlay scene it is the overlay's - // own position within the host NSWindow. - val offset = host.coordinateOffset - PopupNativeBridge.nativeSetFrameInWindow( - panel = panelHandle, - xPx = value.left + offset.x, - yPx = value.top + offset.y, - widthPx = value.width.coerceAtLeast(1), - heightPx = value.height.coerceAtLeast(1), - ) - // Resize the CAMetalLayer's drawable to match the popup's - // actual size. We DON'T resize the inner scene — its size - // stays at parent window size so layout has real constraints. - // Only the visible draw area is constrained to `boundsInWindow`. - val w = value.width.coerceAtLeast(1) - val h = value.height.coerceAtLeast(1) - if (w != widthPx || h != heightPx) { - widthPx = w - heightPx = h - NativeMetalBridge.nativeResize(attachmentHandle, w, h, scale) - } + if (!value.isEmpty) contentBounds = value + updateNativeFrame() host.requestRedraw() } + /** + * Pushes the panel frame — [drawBounds], screen-clamped (#569). + * + * `boundsInWindow` is in the parent scene's coordinate system (top-left + * origin). For host-window-rooted scenes [TaoPopupHost.coordinateOffset] + * is zero; for `NativeView`'s overlay scene it is the overlay's own + * position within the host NSWindow. + * + * The clamp is decided on the content, not the inflated surface: what must + * stay on screen is the popup the user sees, and a shadow margin hanging + * past the edge is what the in-scene layer does too. Only the panel's frame + * moves — [_bounds] and [drawBounds] stay what Compose believes, which is + * what the scene draws in and what [scenePosition] maps pointers back to. + */ + private fun updateNativeFrame() { + if (contentBounds.isEmpty || disposed) return + drawBounds = popupDrawBounds(contentBounds, _density.density) + val offset = host.coordinateOffset + val contentInParent = contentBounds.translate(offset) + val frameInParent = drawBounds.translate(offset) + val geometry = host.popupScreenGeometry + val clamp = popupScreenClampOffset(contentInParent, geometry) + val w = drawBounds.width.coerceAtLeast(1) + val h = drawBounds.height.coerceAtLeast(1) + PopupNativeBridge.nativeSetFrameInWindow( + panel = panelHandle, + xPx = frameInParent.left + clamp.x, + yPx = frameInParent.top + clamp.y, + widthPx = w, + heightPx = h, + ) + // Only the content answers hit-tests; the inflated margin falls through + // to the parent window — where the outside-click monitor picks it up. + PopupNativeBridge.nativeSetInteractiveRegions( + panelHandle, + floatArrayOf( + (contentBounds.left - drawBounds.left).toFloat(), + (contentBounds.top - drawBounds.top).toFloat(), + contentBounds.width.toFloat(), + contentBounds.height.toFloat(), + ), + 1, + ) + geometry?.let { + val onScreen = it.parentContentOriginPx + clamp + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = _bounds, + frameOnScreenPx = frameInParent.translate(onScreen), + contentOnScreenPx = contentInParent.translate(onScreen), + clampOffsetPx = clamp, + panelHandle = panelHandle, + ), + ) + } + // Resize the CAMetalLayer's drawable to match the surface. We DON'T + // resize the inner scene — its size stays at work-area size so layout + // has real constraints. Only the visible draw area follows [drawBounds]. + if (w != widthPx || h != heightPx) { + widthPx = w + heightPx = h + NativeMetalBridge.nativeResize(attachmentHandle, w, h, scale) + } + } + + /** Panel-local physical px → inner-scene (parent-window) coordinates. */ + private fun scenePosition( + x: Float, + y: Float, + ): Offset = Offset(x + drawBounds.left, y + drawBounds.top) + override var compositionLocalContext: CompositionLocalContext? get() = _compositionLocalContext set(value) { @@ -375,9 +519,13 @@ internal class TaoPopupSceneLayer( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value // TODO Phase 4: third surface + scrimColorState.value = value + syncSceneSize() + // The scrim is painted by the owner window's scene and by the layers + // below, none of which observe this state — repaint them. + host.popupScrims.notifyChanged() } override var focusable: Boolean @@ -400,6 +548,7 @@ internal class TaoPopupSceneLayer( override fun close() { host.unregisterRenderer(rendererToken) + host.popupScrims.unregister(rendererToken) // Mark disposed before any teardown so a surface already recorded this // frame is skipped at replay time (TaoRecordedSurface.isAlive). disposed = true @@ -442,7 +591,19 @@ internal class TaoPopupSceneLayer( // Our texture host goes *inside* the replayed locals: those carry // the window scene's host, which would otherwise shadow ours. val body: @Composable () -> Unit = { - CompositionLocalProvider(LocalTaoMetalTextureHost provides metalTextureHost) { + CompositionLocalProvider( + LocalTaoMetalTextureHost provides metalTextureHost, + // Inside the replayed parent locals, and deliberately so + // (#569): `Popup.skiko.kt` reads `LocalWindowInfo` from + // *this* composition to size the box it flips and clips the + // popup inside. The replayed snapshot carries the owner + // window's WindowInfo, which would pin every popup to the + // window — the opposite of what native popup layers exist + // for. `popupWindowInfo` reports the work area, so Compose + // flips against a screen-sized box (still rooted at the + // window; the origin is what the clamp corrects). + LocalWindowInfo provides popupWindowInfo, + ) { content() } } @@ -474,14 +635,10 @@ internal class TaoPopupSceneLayer( } } - override fun calculateLocalPosition(positionInWindow: IntOffset): IntOffset { - // boundsInWindow is in parent-window pixels with a top-left origin; - // popup-local = position - bounds.topLeft. - return IntOffset( - positionInWindow.x - _bounds.left, - positionInWindow.y - _bounds.top, - ) - } + // The scene is laid out in parent-window coordinates and translated at + // replay time (see [recordSurface]), so the popup-local position is the + // window position itself — same contract as the Windows and Linux layers. + override fun calculateLocalPosition(positionInWindow: IntOffset): IntOffset = positionInWindow // ── Per-frame record — driven by host's record pass (main thread) ────── @@ -494,12 +651,16 @@ internal class TaoPopupSceneLayer( if (disposed) return null if (widthPx <= 0 || heightPx <= 0) return null if (attachmentHandle == 0L) return null + syncSceneSize() + // The scene is recorded in window coordinates and replayed translated + // into the surface, which is rooted at [drawBounds]. return TaoRecordedSurface( attachmentHandle = attachmentHandle, directContext = directContext, picture = recordSceneToPicture(sceneBundle, widthPx, heightPx), clearColor = 0x00000000, isAlive = { !disposed }, + pictureOffset = IntOffset(-drawBounds.left, -drawBounds.top), ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt index 04a1dffeb..aae21c64a 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -21,6 +22,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.round import dev.nucleusframework.window.tao.TaoApplication import dev.nucleusframework.window.tao.TaoMouseButton import dev.nucleusframework.window.tao.TaoWindow @@ -42,7 +44,10 @@ import dev.nucleusframework.window.tao.scene.renderGlFrame import dev.nucleusframework.window.tao.scene.withEglContextCurrent import org.jetbrains.skia.DirectContext import org.jetbrains.skia.GLAssembledInterface +import org.jetbrains.skia.Rect import org.jetbrains.skia.makeGLWithInterface +import java.util.logging.Level +import java.util.logging.Logger import kotlin.math.roundToInt /** @@ -93,7 +98,7 @@ internal class TaoPopupSceneLayerLinux( private var _layoutDirection = initialLayoutDirection private var _focusable = initialFocusable private var _bounds: IntRect = IntRect.Zero - private var _scrimColor: Color? = null + private val scrimColorState: MutableState = mutableStateOf(null) private var _compositionLocalContext: CompositionLocalContext? = null private val rendererToken: Any = Any() @@ -109,6 +114,32 @@ internal class TaoPopupSceneLayerLinux( */ private var released = false + /** + * The rectangle the popup window covers, in scene coordinates: [_bounds] + * inflated by [popupDrawBounds] so shadows and the dialog appearance + * animation are not clipped at the layout edge. A press in the margin is + * an outside press — see [sendPointer]. + */ + private var drawBounds: IntRect = IntRect.Zero + + /** + * The last non-empty [_bounds]: what the native surface is sized and placed + * on. `Dialog.skiko.kt`'s disappearance swaps the layer's content for an + * empty `Layout` that only replays the recorded picture, so Compose reports + * a zero-size `boundsInWindow` at the window centre for the whole fade-out. + * An in-scene layer does not care — it draws into the window canvas — but + * this surface must keep covering where the dialog was, or the fade-out + * shows as a square of margin around a point. + */ + private var contentBounds: IntRect = IntRect.Zero + + /** + * Whether the compositor positions this surface (`xdg_popup`) instead of us + * (`wl_subsurface`) — see [decideCompositorPlacement]. Decided at the first + * frame, since a window's map type cannot change afterwards. + */ + private var compositorPlaced: Boolean? = null + /** EGL attachment ready — flips on WINDOW_READY once the GPU side is up. */ private var attachment: Long = 0 private var directContext: DirectContext? = null @@ -142,6 +173,38 @@ internal class TaoPopupSceneLayerLinux( IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) } + /** + * Compose's box for placing this layer's content, as reported through + * `LocalWindowInfo` inside the layer's own composition (#569). + * + * Two answers, because two very different things end up in a scene layer: + * + * - A **popup** (`Popup`, `DropdownMenu`, context menu, tooltip, Jewel's + * combo-box flyout) belongs to the *display*. It gets the work area + * ([sceneLayoutSize]), so `Popup.skiko.kt` lays it out at full size and + * flips it against a screen-sized box instead of against the owner + * window — the point of native popup layers. That box is still rooted at + * the window; the origin is what the screen clamp corrects when the + * frame is pushed. + * - A **dialog** (`Dialog`, Material `AlertDialog`) belongs to its + * *window*: `Dialog.skiko.kt` places it at `containerSize.center`, and a + * window-owned dialog centred on the display would sit visibly + * off-centre — and drift further as the user moved the window. It gets + * the owner window's content size, exactly as before #569. + * + * `scrimColor` is the discriminator, and a sound one: only + * `Dialog.skiko.kt` ever writes it, from + * `DialogAppearanceController.properties` — assigned while `DialogLayout` + * composes, *before* `layer.Content { }` and so before this is read. + * `Popup.skiko.kt` never touches it. Held as snapshot state so a later + * write recomposes the content that read it. + */ + private val dialogContainerSize: IntSize + get() = + host.parentWindowInfo.containerSize.let { + IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) + } + /** * Physical size of the popup's native surface and render target. Always * a multiple of [bufferScale]; the content occupies its top-left and the @@ -170,7 +233,8 @@ internal class TaoPopupSceneLayerLinux( private val popupWindowInfo: androidx.compose.ui.platform.WindowInfo = object : androidx.compose.ui.platform.WindowInfo { override val isWindowFocused: Boolean = true - override val containerSize: IntSize get() = sceneLayoutSize + override val containerSize: IntSize + get() = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize } private val sceneBundle: TaoSceneBundle = @@ -204,23 +268,59 @@ internal class TaoPopupSceneLayerLinux( ).apply { // Report through the owner window's channel — see [TaoPopupHost.exceptionHandler]. exceptionHandler = host.exceptionHandler + // Dim this popup under the dialogs stacked above it. The canvas is + // translated by `-_bounds.topLeft` at this point, so the visible + // surface is `_bounds.topLeft` + the surface size in scene coordinates. + renderOverlay = { canvas -> + host.popupScrims.paintAbove( + rendererToken, + canvas, + Rect.makeXYWH( + drawBounds.left.toFloat(), + drawBounds.top.toFloat(), + widthPx.toFloat(), + heightPx.toFloat(), + ), + ) + } } private val innerScene: ComposeScene get() = sceneBundle.scene + /** + * Keeps the inner scene's size on the box the layer's content lays out in + * (#569). A dialog's root `Layout` fills the scene's constraints, and + * `Dialog.skiko.kt` puts its appearance animation's `GraphicsLayer` on + * that very Layout — so the scale pivots around the *scene's* centre. In + * the window's own scene that box is the window, whose centre is the + * dialog's; a work-area-sized scene would make the dialog slide towards + * the display's centre while it scales in. Popups keep the work area so a + * tall menu can lay out at full height. Re-checked every frame: the window + * may have been resized since. + */ + private fun syncSceneSize() { + val want = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize + if (innerScene.size != want) innerScene.size = want + } + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null init { - popupWindow.onWindowReady { _, _ -> attachGpu() } + trace { "created popup window ${popupWindow.handle} focusable=$_focusable" } + popupWindow.onWindowReady { _, _ -> + trace { "window ready" } + attachGpu() + } // Compositor expose (X11) / re-map: repaint through the host pump. popupWindow.onRedrawRequested { host.requestRedraw() } registerInput() host.registerRenderer(rendererToken) { renderFrame() } + host.popupScrims.register(rendererToken) { scrimColorState.value } host.registerKeyHandler(keyHandlerToken) { dispatchKey(it) } host.registerOwnerMoveListener(moveListenerToken) { - if (_bounds != IntRect.Zero) updateNativeFrame() + if (!contentBounds.isEmpty) updateNativeFrame() } } @@ -276,6 +376,7 @@ internal class TaoPopupSceneLayerLinux( } attachment = handle directContext = ctx + trace { "gpu attached kind=$kind ${w}x$h" } glTextureHostState.value = object : TaoGlTextureHost { override val directContext: DirectContext = ctx @@ -285,7 +386,12 @@ internal class TaoPopupSceneLayerLinux( override fun withContextCurrent(block: () -> T): T? = withEglContextCurrent(attachment, block) } // Re-push any frame set before the window was ready, and paint. - if (_bounds != IntRect.Zero) updateNativeFrame() + if (!contentBounds.isEmpty) updateNativeFrame() + // Paint now, not on the owner's next frame: this first render is what + // measures the content and writes boundsInWindow, i.e. what shows the + // popup at all — waiting for the owner's redraw added a frame or two to + // every menu. The present itself still rides the owner's pump. + renderFrame() host.requestRedraw() } @@ -308,7 +414,9 @@ internal class TaoPopupSceneLayerLinux( override var boundsInWindow: IntRect get() = _bounds set(value) { + trace { "boundsInWindow=$value" } _bounds = value + if (!value.isEmpty) contentBounds = value updateNativeFrame() host.requestRedraw() } @@ -320,9 +428,13 @@ internal class TaoPopupSceneLayerLinux( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value + scrimColorState.value = value + syncSceneSize() + // The scrim is painted by the owner window's scene and by the layers + // below, none of which observe this state — repaint them. + host.popupScrims.notifyChanged() } override var focusable: Boolean @@ -339,10 +451,13 @@ internal class TaoPopupSceneLayerLinux( override fun close() { if (released) return released = true + trace { "close" } host.unregisterRenderer(rendererToken) + host.popupScrims.unregister(rendererToken) host.unregisterKeyHandler(keyHandlerToken) host.unregisterOwnerMoveListener(moveListenerToken) host.unregisterOutsidePressListener(outsidePressToken) + host.releaseCompositorPopup(rendererToken) // Drop the TextureView handle before the context it points at dies: a // late composition must not import onto a closed context. glTextureHostState.value = null @@ -382,7 +497,19 @@ internal class TaoPopupSceneLayerLinux( // this popup window renders through its own EGL + Skia context, so // a TextureView here must import onto that one. val body: @Composable () -> Unit = { - CompositionLocalProvider(LocalTaoGlTextureHost provides glTextureHost) { + CompositionLocalProvider( + LocalTaoGlTextureHost provides glTextureHost, + // Inside the replayed parent locals, and deliberately so + // (#569): `Popup.skiko.kt` reads `LocalWindowInfo` from + // *this* composition to size the box it flips and clips the + // popup inside. The replayed snapshot carries the owner + // window's WindowInfo, which would pin every popup to the + // window — the opposite of what native popup layers exist + // for. `popupWindowInfo` reports the work area, so Compose + // flips against a screen-sized box (still rooted at the + // window; the origin is what the clamp corrects). + LocalWindowInfo provides popupWindowInfo, + ) { content() } } @@ -428,23 +555,81 @@ internal class TaoPopupSceneLayerLinux( * CSD content origin for `popupOf` windows, so we pass content-space * coords here ([TaoPopupHostLinux.parentScreenOriginPx] is zero on * Wayland). + * + * The position is clamped into the hosting display's work area + * ([popupScreenClampOffset], #569) — Compose picked it inside a + * work-area-sized virtual screen rooted at the window, so it can point off + * the real display. Only the window position moves: `_bounds` stays what + * Compose believes, and it is also the space [renderFrame] translates by + * and [scenePosition] maps pointers back through, so the surface content + * and hit-testing are unaffected. Re-clamped on every call, so the + * owner-move listener keeps an open popup on screen during an X11 drag. + * No-op on Wayland, where the host reports no screen geometry. */ private fun updateNativeFrame() { - if (_bounds == IntRect.Zero || released) return + if (contentBounds.isEmpty || released) return + drawBounds = popupDrawBounds(contentBounds, _density.density) val origin = host.parentScreenOriginPx val offset = host.coordinateOffset - val xPx = _bounds.left + offset.x + origin.x - val yPx = _bounds.top + offset.y + origin.y + // The clamp is decided on the content, not the inflated surface: what + // must stay on screen is the popup the user sees, and a shadow margin + // hanging past the edge is what the in-scene layer does too. + val contentInParent = contentBounds.translate(offset) + val frameInParent = drawBounds.translate(offset) + val geometry = host.popupScreenGeometry + val clamp = popupScreenClampOffset(contentInParent, geometry) + val xPx = frameInParent.left + clamp.x + origin.x + val yPx = frameInParent.top + clamp.y + origin.y + geometry?.let { + val onScreen = it.parentContentOriginPx + clamp + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = _bounds, + frameOnScreenPx = frameInParent.translate(onScreen), + contentOnScreenPx = contentInParent.translate(onScreen), + clampOffsetPx = clamp, + panelHandle = popupWindow.handle, + ), + ) + } // Aligned to the surface scale: Compose bounds are arbitrary physical // pixels (odd widths come out of text measurement and half-dp padding // all the time), and a buffer that isn't a multiple of the announced // `buffer_scale` is a fatal Wayland protocol error — the compositor // drops the connection and the process dies (#502). It also keeps the // logical size below an exact integer for GTK. - val w = alignToBufferScale(_bounds.width, bufferScale) - val h = alignToBufferScale(_bounds.height, bufferScale) - popupWindow.setOuterPosition((xPx / scale).toDouble(), (yPx / scale).toDouble()) - popupWindow.setInnerSize((w / scale).toDouble(), (h / scale).toDouble()) + val w = alignToBufferScale(drawBounds.width, bufferScale) + val h = alignToBufferScale(drawBounds.height, bufferScale) + val compositorPlaced = + compositorPlaced ?: decideCompositorPlacement(geometry).also { + compositorPlaced = it + TaoPopupDiagnostics.lastCompositorPlaced = it + } + trace { + "push frame pos=($xPx,$yPx) size=${w}x$h shown=$shown attached=${attachment != 0L} " + + "compositorPlaced=$compositorPlaced" + } + if (compositorPlaced) { + // The compositor owns the position from map on, and GDK positions an + // xdg_popup once — only the frame before show() counts. Neither a + // plain move nor a plain resize here: either would re-map the window + // as a subsurface, so the anchor call carries the size as well. + if (!shown) { + popupWindow.anchorPopupInParent( + contentXDp = contentInParent.left / scale.toDouble(), + contentYDp = contentInParent.top / scale.toDouble(), + widthDp = (w / scale).toDouble(), + heightDp = (h / scale).toDouble(), + shadowLeftDp = ((contentBounds.left - drawBounds.left) / scale).roundToInt(), + shadowTopDp = ((contentBounds.top - drawBounds.top) / scale).roundToInt(), + shadowRightDp = ((drawBounds.right - contentBounds.right) / scale).roundToInt(), + shadowBottomDp = ((drawBounds.bottom - contentBounds.bottom) / scale).roundToInt(), + ) + } + } else { + popupWindow.setOuterPosition((xPx / scale).toDouble(), (yPx / scale).toDouble()) + popupWindow.setInnerSize((w / scale).toDouble(), (h / scale).toDouble()) + } if (w != widthPx || h != heightPx) { widthPx = w heightPx = h @@ -454,12 +639,30 @@ internal class TaoPopupSceneLayerLinux( } if (!shown) { shown = true + trace { "show" } popupWindow.show() } } + /** + * Whether the compositor should place this surface — an `xdg_popup` it + * keeps on screen — rather than us. Only on native Wayland, the one + * backend where the client cannot see the screen and so cannot clamp (X11 + * has [popupScreenClampOffset]); only for popups, since a dialog belongs + * to its window and stays centred in it as a subsurface; and one per + * parent, because an `xdg_popup` must be its parent's topmost popup + * ([TaoPopupHostLinux.acquireCompositorPopup]). + */ + private fun decideCompositorPlacement(geometry: PopupScreenGeometry?): Boolean = + geometry == null && + popupWindow.parentIsNativeWayland() && + scrimColorState.value == null && + host.acquireCompositorPopup(rendererToken) + // ── Per-frame render — driven by the host's redraw pump ─────────────── + private var presented = false + private fun renderFrame() { if (released || attachment == 0L) return if (widthPx <= 0 || heightPx <= 0) return @@ -471,7 +674,8 @@ internal class TaoPopupSceneLayerLinux( // the popup at zero bounds forever. Same bootstrap as the Windows // layer's 1×1 initial drawBounds. The present is skipped until the // frame is real; nothing is on screen yet anyway. - val frame = _bounds + syncSceneSize() + val frame = drawBounds NativeTaoEglBridge.nativeMakeCurrent(attachment) // Private EGL context — no resetGLAll needed (unlike the Windows // shared-process-context path). @@ -484,7 +688,13 @@ internal class TaoPopupSceneLayerLinux( // alpha mode must be stated — see renderGlFrame). windowTransparent = true, present = { - if (frame != IntRect.Zero) NativeTaoEglBridge.nativePresent(attachment) + if (frame != IntRect.Zero) { + if (!presented) { + presented = true + trace { "first present frame=$frame" } + } + NativeTaoEglBridge.nativePresent(attachment) + } }, ) { canvas, nanoTime -> canvas.save() @@ -540,9 +750,19 @@ internal class TaoPopupSceneLayerLinux( if (released) return@catchExceptions lastX = xPx lastY = yPx + val position = scenePosition(xPx, yPx) + // The window is inflated past the layout bounds (see [drawBounds]); a + // press in that margin lands on this window rather than the parent, so + // the parent's outside-press listener never sees it. It is an outside + // press all the same — the Windows content rect and the macOS hit region + // hand it to the parent natively. + if (eventType == PointerEventType.Press && !_bounds.contains(position.round())) { + onOutsidePointerEvent?.invoke(eventType, button) + return@catchExceptions + } innerScene.sendPointerEvent( eventType = eventType, - position = scenePosition(xPx, yPx), + position = position, type = PointerType.Mouse, keyboardModifiers = taoKeyboardModifiers(host.parentWindow.modifierState), button = button, @@ -553,7 +773,7 @@ internal class TaoPopupSceneLayerLinux( private fun scenePosition( x: Float, y: Float, - ): Offset = Offset(x + _bounds.left, y + _bounds.top) + ): Offset = Offset(x + drawBounds.left, y + drawBounds.top) private fun mapButton(code: Int): PointerButton = when (code) { @@ -575,7 +795,13 @@ internal class TaoPopupSceneLayerLinux( return onKeyEvent?.invoke(event) == true } + private fun trace(message: () -> String) { + if (logger.isLoggable(Level.FINE)) logger.fine("popup ${System.identityHashCode(this)}: ${message()}") + } + private companion object { + private val logger: Logger = Logger.getLogger(TaoPopupSceneLayerLinux::class.java.name) + // Wire scale — must match Rust `CURSOR_FIXED_SCALE`. private const val POSITION_SCALE: Float = 1024f diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt index 63056835f..de5d6660e 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -33,6 +34,7 @@ import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.catchExceptions import dev.nucleusframework.window.tao.scene.renderGlFrame import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Rect /** * Windows popup layer backed by a transparent owned WS_POPUP HWND. @@ -68,7 +70,7 @@ internal class TaoPopupSceneLayerWindows( private val layoutDirectionState: MutableState = mutableStateOf(initialLayoutDirection) private var _focusable = initialFocusable private var _bounds: IntRect = IntRect.Zero - private var _scrimColor: Color? = null + private val scrimColorState: MutableState = mutableStateOf(null) private var _compositionLocalContext: CompositionLocalContext? = null private val rendererToken: Any = Any() @@ -93,7 +95,57 @@ internal class TaoPopupSceneLayerWindows( host.workAreaSize.let { IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) } + + /** + * Compose's box for placing this layer's content, as reported through + * `LocalWindowInfo` inside the layer's own composition (#569). + * + * Two answers, because two very different things end up in a scene layer: + * + * - A **popup** (`Popup`, `DropdownMenu`, context menu, tooltip, Jewel's + * combo-box flyout) belongs to the *display*. It gets the work area + * ([sceneLayoutSize]), so `Popup.skiko.kt` lays it out at full size and + * flips it against a screen-sized box instead of against the owner + * window — the point of native popup layers. That box is still rooted at + * the window; the origin is what the screen clamp corrects when the + * frame is pushed. + * - A **dialog** (`Dialog`, Material `AlertDialog`) belongs to its + * *window*: `Dialog.skiko.kt` places it at `containerSize.center`, and a + * window-owned dialog centred on the display would sit visibly + * off-centre — and drift further as the user moved the window. It gets + * the owner window's content size, exactly as before #569. + * + * `scrimColor` is the discriminator, and a sound one: only + * `Dialog.skiko.kt` ever writes it, from + * `DialogAppearanceController.properties` — assigned while `DialogLayout` + * composes, *before* `layer.Content { }` and so before this is read. + * `Popup.skiko.kt` never touches it. Held as snapshot state so a later + * write recomposes the content that read it. + */ + private val dialogContainerSize: IntSize + get() = + host.parentWindowInfo.containerSize.let { + IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) + } + + /** + * The rectangle the HWND covers, in scene coordinates: [_bounds] inflated + * by [popupDrawBounds] so shadows and the dialog appearance animation are + * not clipped at the layout edge. The native side keeps [_bounds] as the + * content rect, so a click in the margin is an outside click. + */ private var drawBounds: IntRect = IntRect(0, 0, 1, 1) + + /** + * The last non-empty [_bounds]: what the native surface is sized and placed + * on. `Dialog.skiko.kt`'s disappearance swaps the layer's content for an + * empty `Layout` that only replays the recorded picture, so Compose reports + * a zero-size `boundsInWindow` at the window centre for the whole fade-out. + * An in-scene layer does not care — it draws into the window canvas — but + * this surface must keep covering where the dialog was, or the fade-out + * shows as a square of margin around a point. + */ + private var contentBounds: IntRect = IntRect.Zero private var widthPx: Int = 1 private var heightPx: Int = 1 @@ -162,7 +214,8 @@ internal class TaoPopupSceneLayerWindows( private val popupWindowInfo: androidx.compose.ui.platform.WindowInfo = object : androidx.compose.ui.platform.WindowInfo { override val isWindowFocused: Boolean = true - override val containerSize: IntSize get() = sceneLayoutSize + override val containerSize: IntSize + get() = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize } private val sceneBundle: TaoSceneBundle = @@ -188,10 +241,41 @@ internal class TaoPopupSceneLayerWindows( ).apply { // Report through the owner window's channel — see [TaoPopupHost.exceptionHandler]. exceptionHandler = host.exceptionHandler + // Dim this popup under the dialogs stacked above it. The canvas is + // translated by `-drawBounds.topLeft` at this point, so the visible + // surface is `drawBounds.topLeft` + the surface size in scene coordinates. + renderOverlay = { canvas -> + host.popupScrims.paintAbove( + rendererToken, + canvas, + Rect.makeXYWH( + drawBounds.left.toFloat(), + drawBounds.top.toFloat(), + widthPx.toFloat(), + heightPx.toFloat(), + ), + ) + } } private val innerScene: ComposeScene get() = sceneBundle.scene + /** + * Keeps the inner scene's size on the box the layer's content lays out in + * (#569). A dialog's root `Layout` fills the scene's constraints, and + * `Dialog.skiko.kt` puts its appearance animation's `GraphicsLayer` on + * that very Layout — so the scale pivots around the *scene's* centre. In + * the window's own scene that box is the window, whose centre is the + * dialog's; a work-area-sized scene would make the dialog slide towards + * the display's centre while it scales in. Popups keep the work area so a + * tall menu can lay out at full height. Re-checked every frame: the window + * may have been resized since. + */ + private fun syncSceneSize() { + val want = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize + if (innerScene.size != want) innerScene.size = want + } + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null @@ -278,8 +362,9 @@ internal class TaoPopupSceneLayerWindows( // Register the per-frame renderer + owner-move listener now; both // defer / no-op until the panel exists. host.registerRenderer(rendererToken) { renderFrame() } + host.popupScrims.register(rendererToken) { scrimColorState.value } host.registerOwnerMoveListener(moveListenerToken) { - if (panelHandle != 0L && _bounds != IntRect.Zero) { + if (panelHandle != 0L && !contentBounds.isEmpty) { updateNativeFrame() } } @@ -305,6 +390,7 @@ internal class TaoPopupSceneLayerWindows( get() = _bounds set(value) { _bounds = value + if (!value.isEmpty) contentBounds = value updateDrawBoundsFromBounds() host.requestRedraw() } @@ -316,9 +402,13 @@ internal class TaoPopupSceneLayerWindows( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value + scrimColorState.value = value + syncSceneSize() + // The scrim is painted by the owner window's scene and by the layers + // below, none of which observe this state — repaint them. + host.popupScrims.notifyChanged() } override var focusable: Boolean @@ -337,6 +427,7 @@ internal class TaoPopupSceneLayerWindows( released = true host.notifyPopupClosing() host.unregisterRenderer(rendererToken) + host.popupScrims.unregister(rendererToken) host.unregisterOwnerMoveListener(moveListenerToken) PopupNativeBridgeWindows.nativeUninstallOutsideClickMonitor(panelHandle) PopupNativeBridgeWindows.nativeSetEventCallback(panelHandle, null) @@ -354,6 +445,17 @@ internal class TaoPopupSceneLayerWindows( CompositionLocalProvider( LocalDensity provides densityState.value, LocalLayoutDirection provides layoutDirectionState.value, + // Inside the replayed parent locals, and deliberately so + // (#569). `Popup.skiko.kt` reads `LocalWindowInfo` from + // *this* composition to size the box it flips and clips the + // popup inside; the replayed snapshot carries the owner + // window's WindowInfo, which would pin every popup to the + // window — the exact opposite of what native popup layers + // exist for. The scene's own `popupWindowInfo` reports the + // work area, so Compose lays out and flips against a + // screen-sized box (still rooted at the window — the + // origin is what [updateNativeFrame]'s clamp corrects). + LocalWindowInfo provides popupWindowInfo, ) { content() } @@ -394,6 +496,7 @@ internal class TaoPopupSceneLayerWindows( if (drawBounds == IntRect.Zero) return if (widthPx <= 0 || heightPx <= 0) return if (!ensurePanel()) return + syncSceneSize() if (!PopupNativeBridgeWindows.nativeMakeCurrent(panelHandle)) return directContext.resetGLAll() @@ -423,14 +526,8 @@ internal class TaoPopupSceneLayerWindows( ): Offset = Offset(x + drawBounds.left, y + drawBounds.top) private fun updateDrawBoundsFromBounds(): Boolean { - if (_bounds == IntRect.Zero) return false - val nextDrawBounds = - IntRect( - left = _bounds.left, - top = _bounds.top, - right = _bounds.right, - bottom = _bounds.bottom, - ) + if (contentBounds.isEmpty) return false + val nextDrawBounds = popupDrawBounds(contentBounds, _density.density) val changed = nextDrawBounds != drawBounds drawBounds = nextDrawBounds widthPx = drawBounds.width.coerceAtLeast(1) @@ -439,22 +536,57 @@ internal class TaoPopupSceneLayerWindows( return changed } + /** + * Pushes the popup frame to its HWND, screen-clamped (#569). + * + * The clamp shifts the **native frame only** — never [drawBounds] or + * [_bounds]. Those two are the popup's *scene* coordinates: [renderFrame] + * translates the inner scene by `-drawBounds` and [scenePosition] maps + * HWND-local pointers back by `+drawBounds`, so shifting them would move + * the content inside the surface by exactly as much as the surface moved + * on screen — a visual no-op — and would desynchronize hit-testing from + * what Compose believes. Only the `SetWindowPos` origin moves; the surface + * content and the coordinate space Compose sees stay untouched. + * + * Re-clamped on every call, so the owner-move listener (see [init]) keeps + * an open popup inside the work area while the window is dragged, and a + * drag onto a second display re-resolves the display too. + */ private fun updateNativeFrame() { if (panelHandle == 0L) return - if (drawBounds == IntRect.Zero || _bounds == IntRect.Zero) return + if (drawBounds == IntRect.Zero || contentBounds.isEmpty) return val offset = host.coordinateOffset - val finalX = drawBounds.left + offset.x - val finalY = drawBounds.top + offset.y + // The clamp is decided on the content, not the inflated surface: what + // must stay on screen is the popup the user sees, and a shadow margin + // hanging past the edge is what the in-scene layer does too. + val contentInParent = contentBounds.translate(offset) + val frameInParent = drawBounds.translate(offset) + val geometry = host.popupScreenGeometry + val clamp = popupScreenClampOffset(contentInParent, geometry) + val finalX = frameInParent.left + clamp.x + val finalY = frameInParent.top + clamp.y + geometry?.let { + val onScreen = it.parentContentOriginPx + clamp + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = _bounds, + frameOnScreenPx = frameInParent.translate(onScreen), + contentOnScreenPx = contentInParent.translate(onScreen), + clampOffsetPx = clamp, + panelHandle = panelHandle, + ), + ) + } PopupNativeBridgeWindows.nativeSetFrameInWindow( panel = panelHandle, xPx = finalX, yPx = finalY, widthPx = drawBounds.width.coerceAtLeast(1), heightPx = drawBounds.height.coerceAtLeast(1), - contentXPx = _bounds.left - drawBounds.left, - contentYPx = _bounds.top - drawBounds.top, - contentWidthPx = _bounds.width.coerceAtLeast(1), - contentHeightPx = _bounds.height.coerceAtLeast(1), + contentXPx = contentBounds.left - drawBounds.left, + contentYPx = contentBounds.top - drawBounds.top, + contentWidthPx = contentBounds.width.coerceAtLeast(1), + contentHeightPx = contentBounds.height.coerceAtLeast(1), ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt index 09c35886d..fd1280d78 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.window.tao.scene +import androidx.compose.ui.unit.IntOffset import dev.nucleusframework.window.tao.ffi.NativeMetalBridge import org.jetbrains.skia.BackendRenderTarget import org.jetbrains.skia.ColorSpace @@ -79,6 +80,12 @@ internal fun replayPictureToFrame( directContext: DirectContext, picture: Picture, clearColor: Int, + /** + * Where the picture's origin lands on the surface. A popup layer records + * its scene in window coordinates and draws it into a surface rooted at + * the layer's draw bounds, so it passes `-drawBounds.topLeft`. + */ + pictureOffset: IntOffset = IntOffset.Zero, present: (handle: Long, drawablePtr: Long) -> Unit = { h, d -> NativeMetalBridge.nativePresent(h, d) }, @@ -100,6 +107,7 @@ internal fun replayPictureToFrame( } try { surface.canvas.clear(clearColor) + surface.canvas.translate(pictureOffset.x.toFloat(), pictureOffset.y.toFloat()) surface.canvas.drawPicture(picture) surface.flushAndSubmit(syncCpu = false) present(attachmentHandle, frame.drawablePtr) @@ -138,4 +146,6 @@ internal class TaoRecordedSurface( NativeMetalBridge.nativePresent(h, d) }, val isAlive: () -> Boolean = { true }, + /** Translation applied before the picture is drawn — see [replayPictureToFrame]. */ + val pictureOffset: IntOffset = IntOffset.Zero, ) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt index d97a54740..f617b5131 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt @@ -7,6 +7,19 @@ import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection +/** + * Builds one native popup layer for a Compose `Popup` / `Dialog` opened in a + * window: the per-platform `TaoPopupSceneLayer*` constructor, with the host + * already bound. Same signature as [ComposeSceneContext.createLayer]. + */ +@OptIn(InternalComposeUiApi::class) +internal typealias TaoPopupLayerFactory = ( + density: Density, + layoutDirection: LayoutDirection, + focusable: Boolean, + consumePointerInputOutside: Boolean, +) -> ComposeSceneLayer + /** * `ComposeSceneContext` that lifts Compose `Popup` / `DropdownMenu` / * `Tooltip` content into a native popup window (an NSPanel on macOS, a Tao @@ -26,12 +39,7 @@ import androidx.compose.ui.unit.LayoutDirection @OptIn(InternalComposeUiApi::class) internal class TaoComposeSceneContext( override val platformContext: PlatformContext, - private val layerFactory: ( - density: Density, - layoutDirection: LayoutDirection, - focusable: Boolean, - consumePointerInputOutside: Boolean, - ) -> ComposeSceneLayer, + private val layerFactory: TaoPopupLayerFactory, ) : ComposeSceneContext { override fun createLayer( density: Density, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index bc52b3843..df7fd76a9 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeScenePointer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowExceptionHandler @@ -28,6 +29,7 @@ import dev.nucleusframework.window.tao.TaoEventCode import dev.nucleusframework.window.tao.TaoFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoKeyLocation import dev.nucleusframework.window.tao.TaoModifierMask +import dev.nucleusframework.window.tao.TaoMonitors import dev.nucleusframework.window.tao.TaoNativeViewHost import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.TaoTrackpadGesture @@ -46,6 +48,8 @@ import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsDecoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsNativeViewBridge import dev.nucleusframework.window.tao.initialMacOsScaleFactor import dev.nucleusframework.window.tao.installContentMeasurer +import dev.nucleusframework.window.tao.popup.PopupScreenGeometry +import dev.nucleusframework.window.tao.popup.PopupScrimRegistry import dev.nucleusframework.window.tao.popup.TaoPopupHost import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayer import dev.nucleusframework.window.tao.render.LocalTaoTextSelectionA11yPublisher @@ -58,7 +62,9 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.skia.Canvas import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Rect import java.util.concurrent.Callable import java.util.concurrent.ExecutorService import java.util.concurrent.locks.LockSupport @@ -414,7 +420,7 @@ internal class TaoComposeSceneHost( isWindowTransparent = fullyTransparent, ) - val hostPopupHost = if (nativePopupLayers) popupHost() else null + val nativeLayerFactory = if (nativePopupLayers) nativePopupLayerFactory() else null // The scene's MonotonicFrameClock is owned by the FrameRecomposer inside the // bundle (Compose 1.12). It matters that the clock exists: without one the // recomposer can't tell when a frame finished and re-fires the invalidation @@ -422,7 +428,7 @@ internal class TaoComposeSceneHost( // itself in `performFrame` (one frame per FrameDispatcher tick, re-scheduling // only while animations remain), so the host no longer sends frames manually. sceneBundle = - if (hostPopupHost != null) { + if (nativeLayerFactory != null) { // Opt-in path (e.g. tray popups): every Popup becomes a native // NSPanel owned by this window, so popup content can extend // beyond — and float independently of — the window bounds. @@ -431,18 +437,7 @@ internal class TaoComposeSceneHost( density = Density(scale), layoutDirection = GlobalLayoutDirection, size = IntSize(widthPx, heightPx), - composeSceneContext = - TaoComposeSceneContext( - platformContext = taoPlatformContext, - ) { density, layoutDirection, focusable, consumeOutside -> - TaoPopupSceneLayer( - host = hostPopupHost, - initialDensity = density, - initialLayoutDirection = layoutDirection, - initialFocusable = focusable, - initialConsumePointerInputOutside = consumeOutside, - ) - }, + composeSceneContext = TaoComposeSceneContext(taoPlatformContext, nativeLayerFactory), // Schedule a frame on the render loop (coalesced); it renders // then waits for the next vsync. See startRenderLoop. requestFrame = { frameDispatcher?.scheduleFrame() }, @@ -463,9 +458,7 @@ internal class TaoComposeSceneHost( ) } scene?.compositionLocalContext = pendingCompositionLocalContext - // Frame failures (recomposition / layout / draw) are caught inside the - // bundle, the single seam all three platforms render through. - sceneBundle?.exceptionHandler = exceptionHandler + configureSceneBundle() // One source of truth for the scene's drop target: the callback below // resolves it through here, and so does an in-process driver. @@ -765,6 +758,40 @@ internal class TaoComposeSceneHost( // each other when multiple popups are active. private val popupRenderers: MutableMap TaoRecordedSurface?> = LinkedHashMap() + /** + * Dialog scrims of the native popup layers, painted over the main scene at + * the end of every frame — see [PopupScrimRegistry]. + */ + private val popupScrims = + PopupScrimRegistry { + sceneBundle?.visualDirty?.set(true) + window.requestRedraw() + } + + /** + * Dialog scrims of native popup layers land on the owner window's surface, + * after its content — Compose Desktop's `onRenderOverlay`. + */ + private fun paintPopupScrims(canvas: Canvas) { + popupScrims.paintAll( + canvas, + Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()), + transparent = fullyTransparent, + ) + } + + /** + * Hooks every main-scene bundle gets: frame failures (recomposition / + * layout / draw) go to the window's exception handler — the single seam + * all three platforms render through — and popup scrims paint after the + * content. + */ + private fun configureSceneBundle() { + val bundle = sceneBundle ?: return + bundle.exceptionHandler = exceptionHandler + bundle.renderOverlay = ::paintPopupScrims + } + // Tao's macOS pipeline intercepts keys before AppKit's responder // chain, so an overlay NSView can't receive `keyDown:` natively. The // host's `onKeyEvent` consults these handlers first; returning `true` @@ -885,6 +912,45 @@ internal class TaoComposeSceneHost( window.requestRedraw() } + /** + * #569: the NSView's own origin on screen — not the window frame's, a + * native title bar sits between them — paired with every screen's + * `visibleFrame`, so a popup layer can clamp against the display it lands + * on instead of the work-area-sized virtual screen Compose positions it in. + */ + private fun resolvePopupScreenGeometry(): PopupScreenGeometry? { + if (!NativeTaoMacOsDecoBridge.isLoaded) return null + val content = + NativeTaoMacOsDecoBridge + .nativeGetContentRect(nsViewHandle) + ?.takeIf { it.size >= 2 } + ?: return null + val areas = TaoMonitors.all(window).map { it.workAreaPx }.ifEmpty { return null } + return PopupScreenGeometry( + parentContentOriginPx = IntOffset(content[0].toInt(), content[1].toInt()), + workAreasPx = areas, + ) + } + + /** + * Builds this window's native popup layers ([TaoPopupSceneLayer]). The + * factory behind [nativePopupLayers], and the one `NativePopupLayers { }` + * hands to a subtree that wants native surfaces while the window's own + * popups stay in-scene. `null` before the NSView is attached. + */ + fun nativePopupLayerFactory(): TaoPopupLayerFactory? { + val popupHost = popupHost() ?: return null + return { density, layoutDirection, focusable, consumeOutside -> + TaoPopupSceneLayer( + host = popupHost, + initialDensity = density, + initialLayoutDirection = layoutDirection, + initialFocusable = focusable, + initialConsumePointerInputOutside = consumeOutside, + ) + } + } + fun popupHost(): TaoPopupHost? { if (nsViewHandle == 0L) return null val outer = this @@ -893,6 +959,7 @@ internal class TaoComposeSceneHost( override val scale: Float get() = outer.scale override val isOwnerWindowTransparent: Boolean get() = outer.fullyTransparent override val parentWindowSize: IntSize get() = IntSize(outer.widthPx, outer.heightPx) + override val parentWindowInfo: androidx.compose.ui.platform.WindowInfo get() = outer.windowInfo override val workAreaSize: IntSize get() { val packed = NativeMetalBridge.nativeOwnerWorkAreaSize(outer.nsViewHandle) if (packed == 0L) return parentWindowSize @@ -900,12 +967,17 @@ internal class TaoComposeSceneHost( val h = (packed and 0xFFFFFFFFL).toInt() return if (w > 0 && h > 0) IntSize(w, h) else parentWindowSize } + + override val popupScreenGeometry: PopupScreenGeometry? + get() = outer.resolvePopupScreenGeometry() override val sceneCoroutineContext: CoroutineContext get() = outer.coroutineContext + outer.flushingDispatcher override val exceptionHandler: WindowExceptionHandler? get() = outer.exceptionHandler + override val popupScrims: PopupScrimRegistry get() = outer.popupScrims + override fun requestRedraw() = outer.window.requestRedraw() override fun registerRenderer( @@ -1523,6 +1595,7 @@ internal class TaoComposeSceneHost( s.directContext, s.picture, s.clearColor, + s.pictureOffset, s.present, ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index 815de2539..7cd002bbf 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset @@ -31,6 +32,7 @@ import dev.nucleusframework.window.tao.TaoApplication import dev.nucleusframework.window.tao.TaoEventCode import dev.nucleusframework.window.tao.TaoGpuRenderContextConsumers import dev.nucleusframework.window.tao.TaoModifierMask +import dev.nucleusframework.window.tao.TaoMonitors import dev.nucleusframework.window.tao.TaoNonFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.TaoTouchEvent @@ -53,6 +55,8 @@ import dev.nucleusframework.window.tao.ffi.NativeTaoEglBridge import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxTouchBridge import dev.nucleusframework.window.tao.hasGlTextureImports import dev.nucleusframework.window.tao.installContentMeasurer +import dev.nucleusframework.window.tao.popup.PopupScreenGeometry +import dev.nucleusframework.window.tao.popup.PopupScrimRegistry import dev.nucleusframework.window.tao.popup.TaoPopupHostLinux import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayerLinux import dev.nucleusframework.window.tao.releaseGlTextureImports @@ -172,6 +176,40 @@ internal class TaoComposeSceneHostLinux( */ private val popupRenderers: MutableMap Unit> = LinkedHashMap() + /** + * Dialog scrims of the native popup layers, painted over the main scene at + * the end of every frame — see [PopupScrimRegistry]. + */ + private val popupScrims = + PopupScrimRegistry { + sceneBundle?.visualDirty?.set(true) + requestRedrawCoalesced() + } + + /** + * Dialog scrims of native popup layers land on the owner window's surface, + * after its content — Compose Desktop's `onRenderOverlay`. + */ + private fun paintPopupScrims(canvas: Canvas) { + popupScrims.paintAll( + canvas, + Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()), + transparent = fullyTransparent, + ) + } + + /** + * Hooks every main-scene bundle gets: frame failures (recomposition / + * layout / draw) go to the window's exception handler — the single seam + * all three platforms render through — and popup scrims paint after the + * content. + */ + private fun configureSceneBundle() { + val bundle = sceneBundle ?: return + bundle.exceptionHandler = exceptionHandler + bundle.renderOverlay = ::paintPopupScrims + } + /** * Key handlers consulted before the main scene's key dispatch. Popup * windows never own keyboard focus on Linux (override-redirect / @@ -179,6 +217,9 @@ internal class TaoComposeSceneHostLinux( */ private val popupKeyHandlers: MutableMap Boolean> = LinkedHashMap() + /** The layer holding this window's `xdg_popup` slot — see [TaoPopupHostLinux.acquireCompositorPopup]. */ + private var compositorPopupOwner: Any? = null + /** Callbacks invoked when the owner window's screen position changes (X11). */ private val ownerMoveListeners: MutableMap Unit> = LinkedHashMap() @@ -220,6 +261,10 @@ internal class TaoComposeSceneHostLinux( */ private var skipDrainBudget: Int = SKIP_DRAIN_BUDGET_PER_FRAME + /** Diagnostics for a frame the swap gate skipped — see [onRedrawRequested]. */ + private var skippedFrames: Int = 0 + private var skippedFrameStartNanos: Long = 0L + /** Parent locals bridged via [setSceneCompositionLocalContext]; applied to the scene once created. */ private var pendingCompositionLocalContext: androidx.compose.runtime.CompositionLocalContext? = null private val flushingDispatcher = FlushingMainDispatcher() @@ -534,18 +579,7 @@ internal class TaoComposeSceneHostLinux( coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, - composeSceneContext = - TaoComposeSceneContext( - platformContext = platformContext, - ) { density, layoutDirection, focusable, consumeOutside -> - TaoPopupSceneLayerLinux( - host = popupHost(), - initialDensity = density, - initialLayoutDirection = layoutDirection, - initialFocusable = focusable, - initialConsumePointerInputOutside = consumeOutside, - ) - }, + composeSceneContext = TaoComposeSceneContext(platformContext, nativePopupLayerFactory()), requestFrame = { requestRedrawCoalesced() }, ) } else { @@ -560,9 +594,7 @@ internal class TaoComposeSceneHostLinux( ) } scene?.compositionLocalContext = pendingCompositionLocalContext - // Frame failures (recomposition / layout / draw) are caught inside the - // bundle, the single seam all three platforms render through. - sceneBundle?.exceptionHandler = exceptionHandler + configureSceneBundle() // Notify popup layers when the host window moves on screen — X11 // popups are positioned in root coordinates and don't auto-track. @@ -1647,8 +1679,18 @@ internal class TaoComposeSceneHostLinux( skipDrainBudget-- flushingDispatcher.drain() } + skippedFrames++ + if (skippedFrameStartNanos == 0L) skippedFrameStartNanos = System.nanoTime() return } + if (skippedFrameStartNanos != 0L) { + val stalledMs = (System.nanoTime() - skippedFrameStartNanos) / 1_000_000 + if (stalledMs >= FRAME_STALL_TRACE_MILLIS) { + linuxHostLogger.fine("frame stalled ${stalledMs}ms on the swap ($skippedFrames skipped)") + } + skippedFrameStartNanos = 0L + skippedFrames = 0 + } skipDrainBudget = SKIP_DRAIN_BUDGET_PER_FRAME val ctx = directContext ?: return @@ -1983,6 +2025,18 @@ internal class TaoComposeSceneHostLinux( if (pressed && outsidePressListeners.isNotEmpty()) { val button = mapButton(buttonCode) for (cb in outsidePressListeners.values.toList()) cb(button) + // Let the scene apply that dismissal before it sees this press. + // The listeners above close whatever popup was open by writing + // Compose state, and the press is about to be dispatched in the + // same turn — so a node that is *disabled while the popup is open* + // is still disabled when the press arrives, and the press does + // nothing. Compose's own `contextMenuOpenDetector` is exactly that + // node, which is why a second right click used to close the context + // menu instead of moving it to the new spot, the way every OS menu + // does. One extra composition per outside press, and only while a + // popup is open. + Snapshot.sendApplyNotifications() + sceneBundle?.composeAndLayoutNow() } currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) @@ -2143,8 +2197,27 @@ internal class TaoComposeSceneHostLinux( } /** - * Plumbing handed to [TaoPopupSceneLayerLinux] instances when - * [nativePopupLayers] is enabled. Mirrors the Windows + * Builds this window's native popup layers ([TaoPopupSceneLayerLinux]). + * The factory behind [nativePopupLayers], and the one `NativePopupLayers { }` + * hands to a subtree that wants native surfaces while the window's own + * popups stay in-scene. [popupHost] is resolved per layer, as it always + * was: a Wayland hide/show rebuilds the EGL pair and the host reads the + * live one. + */ + fun nativePopupLayerFactory(): TaoPopupLayerFactory = + { density, layoutDirection, focusable, consumeOutside -> + TaoPopupSceneLayerLinux( + host = popupHost(), + initialDensity = density, + initialLayoutDirection = layoutDirection, + initialFocusable = focusable, + initialConsumePointerInputOutside = consumeOutside, + ) + } + + /** + * Plumbing handed to [TaoPopupSceneLayerLinux] instances by + * [nativePopupLayerFactory]. Mirrors the Windows * [TaoComposeSceneHostWindows.popupHost] contract, adapted to the Linux * backend: layers are Tao popup windows keyed on [parentWindow], and each * owns a private EGL context so there is no shared DirectContext. @@ -2157,6 +2230,7 @@ internal class TaoComposeSceneHostLinux( override val exceptionHandler: WindowExceptionHandler? get() = outer.exceptionHandler override val parentWindowSize: IntSize get() = IntSize(outer.widthPx, outer.heightPx) + override val parentWindowInfo: androidx.compose.ui.platform.WindowInfo get() = outer.windowInfo override val workAreaSize: IntSize get() = NativeTaoBridge .nativeLinuxPrimaryMonitorWorkArea(outer.window.handle) @@ -2177,6 +2251,18 @@ internal class TaoComposeSceneHostLinux( ?: IntOffset.Zero } + // #569: clamp popups into the real display's work area instead of + // the work-area-sized virtual screen Compose positions against. + // Null on Wayland for the same reason parentScreenOriginPx is zero + // there — a subsurface has no global position to clamp. + override val popupScreenGeometry: PopupScreenGeometry? get() { + if (!outer.isX11) return null + val origin = parentScreenOriginPx + val areas = TaoMonitors.all(outer.window).map { it.workAreaPx } + if (areas.isEmpty()) return null + return PopupScreenGeometry(parentContentOriginPx = origin, workAreasPx = areas) + } + /** * Nested-scene origin only. The hidden-titlebar CSD content origin * used to live here, but [TaoWindow.setOuterPosition] now applies it @@ -2189,6 +2275,8 @@ internal class TaoComposeSceneHostLinux( override val sceneCoroutineContext: CoroutineContext get() = outer.coroutineContext + outer.flushingDispatcher + override val popupScrims: PopupScrimRegistry get() = outer.popupScrims + override fun requestRedraw() = outer.requestRedrawCoalesced() override fun registerRenderer( @@ -2234,6 +2322,17 @@ internal class TaoComposeSceneHostLinux( override fun unregisterOutsidePressListener(token: Any) { outer.outsidePressListeners.remove(token) } + + override fun acquireCompositorPopup(token: Any): Boolean { + val owner = outer.compositorPopupOwner + if (owner != null && owner !== token) return false + outer.compositorPopupOwner = token + return true + } + + override fun releaseCompositorPopup(token: Any) { + if (outer.compositorPopupOwner === token) outer.compositorPopupOwner = null + } } } @@ -2519,6 +2618,9 @@ internal class TaoComposeSceneHostLinux( } private companion object { + /** A run of skipped frames is only worth a line past this. */ + private const val FRAME_STALL_TRACE_MILLIS = 100L + /** * Every attached Linux host, so an outbound drag session can keep * painting the windows it is *not* running in (see [OutboundDragPump]). diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt index ef8cb3420..d2a59a8e1 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt @@ -22,12 +22,14 @@ import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeScenePointer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowExceptionHandler import dev.nucleusframework.window.tao.GlobalLayoutDirection import dev.nucleusframework.window.tao.TaoEventCode import dev.nucleusframework.window.tao.TaoModifierMask +import dev.nucleusframework.window.tao.TaoMonitors import dev.nucleusframework.window.tao.TaoNonFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.TaoTouchEvent @@ -47,6 +49,8 @@ import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsOverlayBridge import dev.nucleusframework.window.tao.hasWindowsTextureImports import dev.nucleusframework.window.tao.installContentMeasurer +import dev.nucleusframework.window.tao.popup.PopupScreenGeometry +import dev.nucleusframework.window.tao.popup.PopupScrimRegistry import dev.nucleusframework.window.tao.popup.TaoPopupHostWindows import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayerWindows import dev.nucleusframework.window.tao.releaseWindowsTextureImports @@ -60,6 +64,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.skia.BackendRenderTarget +import org.jetbrains.skia.Canvas import org.jetbrains.skia.DirectContext import org.jetbrains.skia.FramebufferFormat import org.jetbrains.skia.GLAssembledInterface @@ -253,6 +258,40 @@ internal class TaoComposeSceneHostWindows( */ private val popupRenderers: MutableMap Unit> = LinkedHashMap() + /** + * Dialog scrims of the native popup layers, painted over the main scene at + * the end of every frame — see [PopupScrimRegistry]. + */ + private val popupScrims = + PopupScrimRegistry { + sceneBundle?.visualDirty?.set(true) + window.requestRedraw() + } + + /** + * Dialog scrims of native popup layers land on the owner window's surface, + * after its content — Compose Desktop's `onRenderOverlay`. + */ + private fun paintPopupScrims(canvas: Canvas) { + popupScrims.paintAll( + canvas, + Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()), + transparent = fullyTransparent, + ) + } + + /** + * Hooks every main-scene bundle gets: frame failures (recomposition / + * layout / draw) go to the window's exception handler — the single seam + * all three platforms render through — and popup scrims paint after the + * content. + */ + private fun configureSceneBundle() { + val bundle = sceneBundle ?: return + bundle.exceptionHandler = exceptionHandler + bundle.renderOverlay = ::paintPopupScrims + } + /** * Key handlers consulted before the main scene's key dispatch * (Phase 8). Overlay scenes register here when they hold a focusable @@ -429,24 +468,14 @@ internal class TaoComposeSceneHostWindows( // Opt-in path (e.g. tray popups): every Popup becomes a // transparent WS_POPUP HWND owned by this window, so popup // content can extend beyond — and float independently of — - // the window bounds. popupHost() is non-null here: hwnd and + // the window bounds. The factory is non-null here: hwnd and // directContext were both set above. platformLayersSceneBundle( coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, composeSceneContext = - TaoComposeSceneContext( - platformContext = platformContext, - ) { density, layoutDirection, focusable, consumeOutside -> - TaoPopupSceneLayerWindows( - host = requireNotNull(popupHost()), - initialDensity = density, - initialLayoutDirection = layoutDirection, - initialFocusable = focusable, - initialConsumePointerInputOutside = consumeOutside, - ) - }, + TaoComposeSceneContext(platformContext, requireNotNull(nativePopupLayerFactory())), requestFrame = { window.requestRedraw() }, ) } else { @@ -459,9 +488,7 @@ internal class TaoComposeSceneHostWindows( ) } scene?.compositionLocalContext = pendingCompositionLocalContext - // Frame failures (recomposition / layout / draw) are caught inside the - // bundle, the single seam all three platforms render through. - sceneBundle?.exceptionHandler = exceptionHandler + configureSceneBundle() publishWindowsTextureHost() // One source of truth for the scene's drop target: the callback below @@ -1569,6 +1596,49 @@ internal class TaoComposeSceneHostWindows( } } + /** + * #569: the client origin `nativeSetFrameInWindow` adds via + * `ClientToScreen`, paired with every display's work area — so a popup + * layer can clamp against the display it actually lands on instead of the + * work-area-sized virtual screen Compose positions it in. + * + * Both halves are live reads rather than a cached snapshot: the layers + * re-clamp on every owner move, so a window dragged to another monitor + * re-resolves the display too. + */ + private fun resolvePopupScreenGeometry(): PopupScreenGeometry? { + if (!NativeTaoWindowsDecoBridge.isLoaded) return null + val origin = + NativeTaoWindowsDecoBridge + .nativeClientToScreen(hwnd, 0, 0) + ?.takeIf { it.size >= 2 } + ?: return null + val areas = TaoMonitors.all(window).map { it.workAreaPx }.ifEmpty { return null } + return PopupScreenGeometry( + parentContentOriginPx = IntOffset(origin[0], origin[1]), + workAreasPx = areas, + ) + } + + /** + * Builds this window's native popup layers ([TaoPopupSceneLayerWindows]). + * The factory behind [nativePopupLayers], and the one `NativePopupLayers { }` + * hands to a subtree that wants native surfaces while the window's own + * popups stay in-scene. `null` until the HWND and its Skia context exist. + */ + fun nativePopupLayerFactory(): TaoPopupLayerFactory? { + val popupHost = popupHost() ?: return null + return { density, layoutDirection, focusable, consumeOutside -> + TaoPopupSceneLayerWindows( + host = popupHost, + initialDensity = density, + initialLayoutDirection = layoutDirection, + initialFocusable = focusable, + initialConsumePointerInputOutside = consumeOutside, + ) + } + } + fun popupHost(): TaoPopupHostWindows? { if (hwnd == 0L) return null val ctx = directContext ?: return null @@ -1578,6 +1648,7 @@ internal class TaoComposeSceneHostWindows( override val scale: Float get() = outer.scale override val isOwnerWindowTransparent: Boolean get() = outer.fullyTransparent override val parentWindowSize: IntSize get() = IntSize(outer.widthPx, outer.heightPx) + override val parentWindowInfo: androidx.compose.ui.platform.WindowInfo get() = outer.windowInfo override val workAreaSize: IntSize get() { if (!NativeTaoWindowsDecoBridge.isLoaded) return parentWindowSize val area = @@ -1589,6 +1660,9 @@ internal class TaoComposeSceneHostWindows( val h = area[3].toInt().coerceAtLeast(1) return IntSize(w, h) } + + override val popupScreenGeometry: PopupScreenGeometry? + get() = outer.resolvePopupScreenGeometry() override val sceneCoroutineContext: kotlin.coroutines.CoroutineContext get() = outer.coroutineContext + outer.flushingDispatcher override val hostDirectContext: DirectContext get() = ctx @@ -1596,6 +1670,8 @@ internal class TaoComposeSceneHostWindows( override val exceptionHandler: WindowExceptionHandler? get() = outer.exceptionHandler + override val popupScrims: PopupScrimRegistry get() = outer.popupScrims + override fun requestRedraw() = outer.window.requestRedraw() override fun registerRenderer( diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt index 855cd8c16..14a478560 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt @@ -119,6 +119,15 @@ internal class TaoSceneBundle( else -> false } + /** + * Painted over the scene at the end of every [render], on the same canvas + * and in the same coordinates the scene drew in. This is where the dialog + * scrims of native popup layers land — the owner window paints every + * layer's scrim, each layer paints the scrims of the layers above it + * (Compose Desktop's `onRenderOverlay`). `null` paints nothing. + */ + var renderOverlay: ((Canvas) -> Unit)? = null + /** * Recomposes, lays out, and draws one frame into [canvas] — the drop-in * replacement for the pre-1.12 `scene.render(canvas.asComposeCanvas(), nanoTime)`. @@ -134,6 +143,7 @@ internal class TaoSceneBundle( with(renderingScope) { scene.render(frameRecomposer, canvas.asComposeCanvas(), nanoTime) } + renderOverlay?.invoke(canvas) edtGuard.afterFrame() swallowed = false } @@ -146,6 +156,26 @@ internal class TaoSceneBundle( if (swallowed && isRecomposerAlive) requestFrame() } + /** + * Recomposes and re-lays-out the scene now, without drawing. + * + * For the one case where a Compose state write has to reach the node tree + * *between* two things that happen in the same turn, rather than on the + * next frame: a press that dismisses a popup, which the scene then receives + * (see `TaoComposeSceneHostLinux.onPointerButton`). Two frame rolls, because + * the first one composes the nodes and only the second runs the effects they + * launched — a `pointerInput` handler awaits from a coroutine, so a node + * composed but not yet started would let the press through untouched. + */ + fun composeAndLayoutNow() { + exceptionHandler.catchExceptions { + val nanoTime = System.nanoTime() + frameRecomposer.performFrame(nanoTime) + frameRecomposer.performFrame(nanoTime) + scene.measureAndLayout() + } + } + @Suppress("TooGenericExceptionCaught") override fun close() { closed.set(true) diff --git a/decorated-window-tao/src/main/native/macos/decoration.m b/decorated-window-tao/src/main/native/macos/decoration.m index a314aa7f6..65fbe4a25 100644 --- a/decorated-window-tao/src/main/native/macos/decoration.m +++ b/decorated-window-tao/src/main/native/macos/decoration.m @@ -14,6 +14,9 @@ // - nativeGetWindowRect: returns the NSWindow's outer frame in physical // pixels using a top-left origin (matching Win32 `GetWindowRect`), so the // Kotlin centring math is the same on every platform. +// - nativeGetContentRect: same convention, but for the view's own rect on +// screen — the origin window-rooted Compose coordinates are relative to, +// which the #569 popup screen clamp converts through. // - nativeGetPrimaryMonitorWorkArea: returns NSScreen.visibleFrame for the // primary screen, in physical pixels with top-left origin (matches the // Windows `SystemParametersInfo(SPI_GETWORKAREA)` shape). @@ -155,6 +158,32 @@ static jlongArray make_rect_array(JNIEnv *env, NSRect r, CGFloat scale) { return make_rect_array(env, topLeft, window.backingScaleFactor); } +/* Returns the *content* rect of the view's window — the rect window-rooted + * Compose coordinates are relative to — as `[x, y, width, height]` in physical + * pixels with a top-left origin, i.e. the same space nativeGetWindowRect and + * nativeGetMonitors report in. + * + * Distinct from nativeGetWindowRect: a window with a native title bar has its + * content origin below the frame origin, and the #569 popup screen clamp is + * only as accurate as this offset. `convertRectToScreen:` is asked for the + * view's own bounds rather than the window's contentLayoutRect so a nested + * overlay view answers for itself. */ +JNIEXPORT jlongArray JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsDecoBridge_nativeGetContentRect( + JNIEnv *env, jclass clazz, jlong nsViewLong) +{ + (void)clazz; + if (!nsViewLong) return NULL; + NSView *view = (__bridge NSView *)(void *)(uintptr_t)nsViewLong; + NSWindow *window = view.window; + if (!window) return NULL; + + NSRect inWindow = [view convertRect:view.bounds toView:nil]; + NSRect onScreen = [window convertRectToScreen:inWindow]; + NSRect topLeft = to_top_left_rect(onScreen); + return make_rect_array(env, topLeft, window.backingScaleFactor); +} + JNIEXPORT jlongArray JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsDecoBridge_nativeGetPrimaryMonitorWorkArea( JNIEnv *env, jclass clazz) diff --git a/decorated-window-tao/src/main/native/src/event_loop.rs b/decorated-window-tao/src/main/native/src/event_loop.rs index 1fe789890..1e90aec8d 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -769,6 +769,44 @@ pub(crate) fn run_event_loop_blocking() { } } } + UserEvent::PopupAnchor { + handle, + x, + y, + width, + height, + shadow_left, + shadow_top, + shadow_right, + shadow_bottom, + } => { + #[cfg(target_os = "linux")] + { + use tao::platform::unix::WindowExtUnix; + let guard = WINDOWS.lock().unwrap(); + if let Some(w) = guard.as_ref().and_then(|map| map.get(&handle)) { + w.popup_anchor( + x, + y, + width, + height, + (shadow_left, shadow_right, shadow_top, shadow_bottom), + ); + } + } + #[cfg(not(target_os = "linux"))] + let _ = ( + handle, + x, + y, + width, + height, + shadow_left, + shadow_top, + shadow_right, + shadow_bottom, + ); + } UserEvent::SetFullscreen { handle, fullscreen } => { let guard = WINDOWS.lock().unwrap(); if let Some(map) = guard.as_ref() { diff --git a/decorated-window-tao/src/main/native/src/events.rs b/decorated-window-tao/src/main/native/src/events.rs index 2bfbb4ca2..174a54062 100644 --- a/decorated-window-tao/src/main/native/src/events.rs +++ b/decorated-window-tao/src/main/native/src/events.rs @@ -372,6 +372,19 @@ pub(crate) enum UserEvent { x: f64, y: f64, }, + /// Linux: anchor a popup overlay at a logical point of its parent so GDK + /// maps it as a compositor-positioned `xdg_popup` (see `popup_anchor`). + PopupAnchor { + handle: u64, + x: i32, + y: i32, + width: i32, + height: i32, + shadow_left: i32, + shadow_top: i32, + shadow_right: i32, + shadow_bottom: i32, + }, SetFullscreen { handle: u64, fullscreen: bool, diff --git a/decorated-window-tao/src/main/native/src/window_jni.rs b/decorated-window-tao/src/main/native/src/window_jni.rs index e6e4d09b8..ab9ad33bf 100644 --- a/decorated-window-tao/src/main/native/src/window_jni.rs +++ b/decorated-window-tao/src/main/native/src/window_jni.rs @@ -511,6 +511,34 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ }); } +/// Linux only: see `UserEvent::PopupAnchor`. Logical parent-window pixels. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeLinuxPopupAnchor( + _env: JNIEnv, + _class: JClass, + handle: jlong, + x: jint, + y: jint, + width: jint, + height: jint, + shadow_left: jint, + shadow_top: jint, + shadow_right: jint, + shadow_bottom: jint, +) { + send_user_event(UserEvent::PopupAnchor { + handle: handle as u64, + x, + y, + width, + height, + shadow_left, + shadow_top, + shadow_right, + shadow_bottom, + }); +} + #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeIsFullscreen( _env: JNIEnv, diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs index 7e151f692..80b649b3f 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs @@ -98,6 +98,11 @@ pub trait WindowExtUnix { /// point leaves the candidate window free to sit on top of the composition. /// Callers that know the caret's size should use this. fn set_ime_cursor_area, S: Into>(&self, position: P, size: S); + + /// Nucleus patch: anchor a popup overlay (`with_popup_transient_for`) at a + /// logical point of its parent so GDK maps it as a compositor-positioned + /// `xdg_popup`. See the platform `Window::popup_anchor`. + fn popup_anchor(&self, x: i32, y: i32, width: i32, height: i32, shadow: (i32, i32, i32, i32)); } impl WindowExtUnix for Window { @@ -128,6 +133,10 @@ impl WindowExtUnix for Window { fn set_ime_cursor_area, S: Into>(&self, position: P, size: S) { self.window.set_ime_cursor_area(position, size); } + + fn popup_anchor(&self, x: i32, y: i32, width: i32, height: i32, shadow: (i32, i32, i32, i32)) { + self.window.popup_anchor(x, y, width, height, shadow); + } } pub trait WindowBuilderExtUnix { diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs index 9d0ab3fb9..2c1c24ec7 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs @@ -325,6 +325,13 @@ impl EventLoop { match request { WindowRequest::Title(title) => window.set_title(&title), WindowRequest::Position((x, y)) => window.move_(x, y), + WindowRequest::PopupAnchor { + x, + y, + width, + height, + shadow, + } => popup_anchor(&window, x, y, width, height, shadow), WindowRequest::Size((w, h)) => { // Nucleus patch: `gtk_window_resize` is a no-op on non-resizable // windows (GTK follows the content's natural size instead); route @@ -1613,3 +1620,77 @@ impl ResizeDirection { } } } + +/// Nucleus patch: the compositor-positioned popup behind +/// `Window::popup_anchor`. `gdk_window_move_to_rect` arrived in GDK 3.24; it +/// is resolved at run time so the library still loads against 3.22, where the +/// request degrades to the plain move a subsurface popup gets. +fn popup_anchor( + window: >k::Window, + x: i32, + y: i32, + width: i32, + height: i32, + shadow: (i32, i32, i32, i32), +) { + use glib::translate::ToGlibPtr; + type MoveToRect = unsafe extern "C" fn( + *mut gdk::ffi::GdkWindow, + *const gdk::ffi::GdkRectangle, + i32, + i32, + i32, + i32, + i32, + ); + extern "C" { + fn dlsym(handle: *mut std::ffi::c_void, symbol: *const std::os::raw::c_char) -> *mut std::ffi::c_void; + } + const GDK_GRAVITY_NORTH_WEST: i32 = 1; + const GDK_ANCHOR_FLIP_X: i32 = 1 << 0; + const GDK_ANCHOR_FLIP_Y: i32 = 1 << 1; + const GDK_ANCHOR_SLIDE_X: i32 = 1 << 2; + const GDK_ANCHOR_SLIDE_Y: i32 = 1 << 3; + let (left, right, top, bottom) = shadow; + // RTLD_DEFAULT: GDK is already loaded into the process. + let symbol = unsafe { dlsym(std::ptr::null_mut(), b"gdk_window_move_to_rect\0".as_ptr() as *const _) }; + if symbol.is_null() { + window.move_(x - left, y - top); + return; + } + let move_to_rect: MoveToRect = unsafe { std::mem::transmute(symbol) }; + // A popup menu maps as an xdg_popup on Wayland even where GDK would ignore + // the positioner; harmless on X11 (a menu-typed override-redirect window). + window.set_type_hint(gdk::WindowTypeHint::PopupMenu); + // The positioner GDK builds at map time takes the window's geometry as it + // stands, so the real size must be in place *before* `move_to_rect` — hence + // the size request, the realize and the resize pass here rather than a + // separate `WindowRequest::Size`. Popup overlays are non-resizable, where + // `gtk_window_resize` is a no-op and the size request is what counts. + if width > 0 && height > 0 { + window.set_size_request(width, height); + window.resize(width, height); + } + if !window.is_realized() { + window.realize(); + } + window.check_resize(); + let Some(gdk_window) = window.window() else { + return; + }; + // GTK only manages the shadow width of client-decorated windows, so this + // sticks: the xdg window geometry becomes the content, margins excluded. + gdk_window.set_shadow_width(left, right, top, bottom); + let rect = gdk::Rectangle::new(x, y, 1, 1); + unsafe { + move_to_rect( + gdk_window.to_glib_none().0, + rect.to_glib_none().0, + GDK_GRAVITY_NORTH_WEST, + GDK_GRAVITY_NORTH_WEST, + GDK_ANCHOR_FLIP_X | GDK_ANCHOR_FLIP_Y | GDK_ANCHOR_SLIDE_X | GDK_ANCHOR_SLIDE_Y, + 0, + 0, + ); + } +} diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs index d782a9fd7..6b25dc9e6 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs @@ -1021,6 +1021,36 @@ impl Window { /// off it, which is why the caret's *size* matters here and not on Windows. /// GDK works in logical pixels, so the caller's physical rect is scaled down /// on the way in. + /// Nucleus patch: anchor a `GTK_WINDOW_POPUP` overlay at a point of its + /// transient parent through `gdk_window_move_to_rect`, so GDK maps it 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` + /// it lets hang off the display. `(x, y)` are logical parent-window + /// coordinates of the content's top-left; `shadow` = (left, right, top, + /// bottom) transparent margins the surface carries around that content, + /// declared as the popup's shadow width so the compositor constrains the + /// content, not the margin. `width`/`height` are the whole surface in + /// logical pixels, applied here rather than left to a separate size request: + /// GDK builds the `xdg_positioner` from the window's *current* geometry, so a + /// popup still sized 1×1 at this point asks the compositor to constrain a + /// 1×1 rectangle and never gets flipped. GDK positions a popup once, at map: + /// call before the window is shown. + pub fn popup_anchor(&self, x: i32, y: i32, width: i32, height: i32, shadow: (i32, i32, i32, i32)) { + if let Err(e) = self.window_requests_tx.send(( + self.window_id, + WindowRequest::PopupAnchor { + x, + y, + width, + height, + shadow, + }, + )) + { + log::warn!("Fail to send popup anchor request: {}", e); + } + } + pub fn set_ime_cursor_area, S: Into>(&self, position: P, size: S) { let scale_factor = self.scale_factor(); let (x, y): (i32, i32) = position.into().to_logical::(scale_factor).into(); @@ -1362,6 +1392,15 @@ pub enum WindowRequest { /// Nucleus patch (nucleusframework#558): the rectangle the caret occupies, /// in window-local logical pixels, for the input method to steer clear of. SetImeCursorArea((i32, i32, i32, i32)), + /// Nucleus patch: anchor a popup overlay at a point of its transient parent + /// through `gdk_window_move_to_rect` — see `Window::popup_anchor`. + PopupAnchor { + x: i32, + y: i32, + width: i32, + height: i32, + shadow: (i32, i32, i32, i32), + }, WireUpEvents { transparent: bool, fullscreen: bool, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NativePopupLayersTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NativePopupLayersTest.kt new file mode 100644 index 000000000..b0e4d6cde --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NativePopupLayersTest.kt @@ -0,0 +1,170 @@ +@file:OptIn(androidx.compose.ui.InternalComposeUiApi::class) + +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.scene.ComposeSceneLayer +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import dev.nucleusframework.window.tao.scene.runTaoSceneTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [NativePopupLayers] on a real `CanvasLayersComposeScene` — the scene a + * window without `nativePopupLayers` runs on. The window's factory is a + * recording fake: what matters here is *which* pipeline a `Popup` ends up in, + * not what the native layer draws. + */ +class NativePopupLayersTest { + @Test + fun `a Popup inside NativePopupLayers is built by the window's native layer factory`() { + val factory = RecordingLayerFactory() + runTaoSceneTest(width = 100, height = 100) { + setContent { + CompositionLocalProvider(LocalTaoNativePopupLayerFactory provides factory::create) { + Box(Modifier.fillMaxSize().background(Color.White)) { + NativePopupLayers { + Popup(offset = IntOffset(20, 20), properties = PopupProperties(focusable = true)) { + Box(Modifier.size(30.dp).background(Color.Blue)) + } + } + } + } + } + frame() + val layer = factory.layers.single() + assertTrue(layer.contentSet, "Popup content must be handed to the native layer") + assertTrue(layer.focusable, "the Popup's properties must reach the native layer") + // The in-scene pipeline was bypassed: nothing paints the popup here. + assertEquals(WHITE, pixelAt(30, 30)) + } + } + + @Test + fun `a Popup outside NativePopupLayers keeps drawing in the scene`() { + val factory = RecordingLayerFactory() + runTaoSceneTest(width = 100, height = 100) { + setContent { + CompositionLocalProvider(LocalTaoNativePopupLayerFactory provides factory::create) { + Box(Modifier.fillMaxSize().background(Color.White)) { + NativePopupLayers { } + Popup(offset = IntOffset(20, 20)) { + Box(Modifier.size(30.dp).background(Color.Blue)) + } + } + } + } + frame() + assertTrue(factory.layers.isEmpty(), "the opt-in must not leak out of its subtree") + assertEquals(BLUE, pixelAt(30, 30)) + } + } + + @Test + fun `without a native layer factory NativePopupLayers is a no-op`() { + runTaoSceneTest(width = 100, height = 100) { + setContent { + Box(Modifier.fillMaxSize().background(Color.White)) { + NativePopupLayers { + Popup(offset = IntOffset(20, 20)) { + Box(Modifier.size(30.dp).background(Color.Blue)) + } + } + } + } + frame() + assertEquals(BLUE, pixelAt(30, 30)) + } + } + + @Test + fun `closing the Popup closes the native layer`() { + val factory = RecordingLayerFactory() + runTaoSceneTest(width = 100, height = 100) { + setContent { + CompositionLocalProvider(LocalTaoNativePopupLayerFactory provides factory::create) { + NativePopupLayers { + Popup { Box(Modifier.size(30.dp)) } + } + } + } + frame() + assertFalse(factory.layers.single().closed) + setContent { } + frame() + assertTrue(factory.layers.single().closed) + } + } +} + +private const val WHITE = 0xFFFFFFFF.toInt() +private const val BLUE = 0xFF0000FF.toInt() + +private class RecordingLayerFactory { + val layers = mutableListOf() + + fun create( + density: Density, + layoutDirection: LayoutDirection, + focusable: Boolean, + consumePointerInputOutside: Boolean, + ): ComposeSceneLayer = + RecordingLayer(density, layoutDirection, focusable, consumePointerInputOutside).also { layers += it } +} + +/** A [ComposeSceneLayer] that records what Compose asks of it and composes nothing. */ +private class RecordingLayer( + override var density: Density, + override var layoutDirection: LayoutDirection, + override var focusable: Boolean, + override var consumePointerInputOutside: Boolean, +) : ComposeSceneLayer { + override var boundsInWindow: IntRect = IntRect.Zero + override var compositionLocalContext: CompositionLocalContext? = null + override var scrimColor: Color? = null + var contentSet = false + var closed = false + + override fun close() { + closed = true + } + + override fun setContent( + parentCompositionContext: CompositionContext, + content: @Composable () -> Unit, + ) { + contentSet = true + } + + override fun setKeyEventListener( + onPreviewKeyEvent: ((KeyEvent) -> Boolean)?, + onKeyEvent: ((KeyEvent) -> Boolean)?, + ) = Unit + + override fun setOutsidePointerEventListener( + onOutsidePointerEvent: ((eventType: PointerEventType, button: PointerButton?) -> Unit)?, + ) = Unit + + override fun calculateLocalPosition(positionInWindow: IntOffset): IntOffset = + positionInWindow - boundsInWindow.topLeft +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index c1e3b31e9..733b4e372 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -361,6 +361,18 @@ public object TaoSceneTestBattery { run("TaoSceneScrollTest: scrolled content repaints at the new offset") { TaoSceneScrollTest().`scrolled content repaints at the new offset`() } + run("NativePopupLayersTest: a Popup inside NativePopupLayers is built by the window's native layer factory") { + NativePopupLayersTest().`a Popup inside NativePopupLayers is built by the window's native layer factory`() + } + run("NativePopupLayersTest: a Popup outside NativePopupLayers keeps drawing in the scene") { + NativePopupLayersTest().`a Popup outside NativePopupLayers keeps drawing in the scene`() + } + run("NativePopupLayersTest: without a native layer factory NativePopupLayers is a no-op") { + NativePopupLayersTest().`without a native layer factory NativePopupLayers is a no-op`() + } + run("NativePopupLayersTest: closing the Popup closes the native layer") { + NativePopupLayersTest().`closing the Popup closes the native layer`() + } run("TaoScenePopupTest: popup renders above the window content") { TaoScenePopupTest().`popup renders above the window content`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index b268a3667..2b3eb93e0 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt @@ -56,6 +56,7 @@ class TaoSceneTestBatteryDriftTest { private val batteryClasses: List> = listOf( TaoKeyMappingTest::class.java, + NativePopupLayersTest::class.java, TaoKeyboardModifiersDecodeTest::class.java, TaoSyntheticMouseWheelEventTest::class.java, Win32WheelDeltaTest::class.java, @@ -125,6 +126,16 @@ class TaoSceneTestBatteryDriftTest { "pure state mapping + geometry provider evaluation, no ComposeScene", TaoMonitorsTest::class.java to "parses the native monitor wire format; no ComposeScene", + dev.nucleusframework.window.tao.popup.PopupScreenClampTest::class.java to + "pure-function popup screen clamp geometry (#569); no ComposeScene", + dev.nucleusframework.window.tao.popup.PopupDrawInflateTest::class.java to + "pure-function popup draw margin geometry (#569); no ComposeScene", + dev.nucleusframework.window.tao.popup.PopupScrimRegistryTest::class.java to + "scrim bookkeeping + raster blend on a CPU bitmap (#569); no ComposeScene", + dev.nucleusframework.window.tao.popup.PopupDrawInflateTest::class.java to + "pure-function popup draw margin geometry (#569); no ComposeScene", + dev.nucleusframework.window.tao.popup.PopupScrimRegistryTest::class.java to + "scrim bookkeeping + raster blend on a CPU bitmap (#569); no ComposeScene", LcdTextCaptureTest::class.java to "writes an AWT comparison PNG; diagnostic, not a scene behaviour", ) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt new file mode 100644 index 000000000..8277f0b44 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt @@ -0,0 +1,593 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import java.awt.Rectangle +import java.awt.Robot +import java.awt.image.BufferedImage +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Measures the appearance of a Compose `Dialog` as the user sees it — pixels + * grabbed from the screen while it opens — once drawn in the window's own + * scene and once as a native popup layer, and compares the two. + * + * `Dialog.skiko.kt` animates a dialog in over 200 ms: the scrim fades in, the + * content fades from 20 % alpha, scales up from 95 % and slides up 10 dp. + * Nothing in the layer API says so; a native layer only sees `scrimColor` + * writes and a `boundsInWindow`. The only way to know that a real OS surface + * reproduces the in-scene look is to film both and compare the curves: when + * the dialog first shows, how far it slides, how long the scrim and the + * content take to settle. + * + * The three cases run in order and share [Sample]s through [measured]; the + * first two film, the third compares and prints both curves side by side so a + * difference can be read off the log. + */ +internal object DialogAppearanceHeadfulCases { + fun all(): List = + listOf( + film(native = false), + film(native = true), + compare(), + film(native = false, material = true), + film(native = true, material = true), + compare(material = true), + translated(native = false), + translated(native = true), + compareTranslated(), + ) + + /** One screen grab: [tMs] after the dialog was shown. */ + internal class Sample( + val tMs: Long, + /** Red channel of the white background under the scrim (255 = no scrim). */ + val scrimRed: Int, + /** Top and bottom of the dialog's colour on the centre column, or null when not visible. */ + val dialogTop: Int?, + val dialogBottom: Int?, + /** Blue minus red at the dialog's centre; grows as the dialog fades in. */ + val blueness: Int, + ) + + internal class Curve( + val all: List, + /** When the dialog was asked to close; samples from here on film the disappearance. */ + val hideAtMs: Long, + ) { + /** The appearance: from the show request until the hide request. */ + val samples: List get() = all.filter { it.tMs < hideAtMs } + + /** The disappearance: from the hide request on. */ + val hiding: List get() = all.filter { it.tMs >= hideAtMs } + val visible: List get() = samples.filter { it.dialogTop != null } + + /** First moment after the hide request where the dialog started to change. */ + val hideStartMs: Long? + get() { + val rest = hiding.firstOrNull() ?: return null + return hiding + .firstOrNull { + it.dialogTop != rest.dialogTop || + it.blueness != rest.blueness || + it.scrimRed != rest.scrimRed + }?.tMs + ?.minus(hideAtMs) + } + + /** + * The smallest height the dialog's colour spanned while fading out, + * as a fraction of its resting height. `Dialog.skiko.kt` reports a + * zero-size `boundsInWindow` during the fade-out; a native surface that + * followed it shrank the dialog to a square of margin around a point. + */ + val hideMinHeightRatio: Float? + get() { + val rest = visible.lastOrNull() ?: return null + val restHeight = (rest.dialogBottom!! - rest.dialogTop!!).coerceAtLeast(1) + val fading = hiding.filter { it.dialogTop != null && it.dialogBottom != null } + if (fading.isEmpty()) return null + return fading.minOf { it.dialogBottom!! - it.dialogTop!! }.toFloat() / restHeight + } + + /** First moment after the hide request where the dialog was gone. */ + val hideGoneMs: Long? get() = hiding.firstOrNull { it.dialogTop == null }?.tMs?.minus(hideAtMs) + + /** + * Grabs during an animation that show exactly the frame before them. + * The screen is grabbed faster than the display refreshes, so a few + * repeats are normal; many more than the in-scene layer shows means + * frames were dropped. + */ + fun stalls(phase: List): Int = + phase + .zipWithNext() + .count { (a, b) -> + a.dialogTop == b.dialogTop && + a.dialogBottom == b.dialogBottom && + a.blueness == b.blueness && + a.scrimRed == b.scrimRed + } + + val showStalls: Int + get() { + val end = settledMs ?: return 0 + return stalls(visible.filter { it.tMs <= end }) + } + + val hideStalls: Int + get() { + val start = hideStartMs ?: return 0 + val end = hideGoneMs ?: return 0 + return stalls(hiding.filter { it.tMs - hideAtMs in start..end }) + } + val firstVisibleMs: Long? get() = visible.firstOrNull()?.tMs + val finalTop: Int? get() = visible.lastOrNull()?.dialogTop + val finalBlueness: Int get() = visible.lastOrNull()?.blueness ?: 0 + val finalScrimRed: Int get() = samples.lastOrNull()?.scrimRed ?: WHITE + + /** How far below its resting place the dialog first appeared, in logical px. */ + val slideInPx: Int? + get() { + val first = visible.firstOrNull()?.dialogTop ?: return null + val last = finalTop ?: return null + return first - last + } + + /** How long the appearance animated on screen, from its first frame to its last change. */ + val animationMs: Long? + get() { + val first = firstVisibleMs ?: return null + val end = settledMs ?: return null + return end - first + } + + /** First moment after which position, content alpha and scrim all stay at their final values. */ + val settledMs: Long? + get() { + val top = finalTop ?: return null + val settled = + visible.takeLastWhile { + abs(it.dialogTop!! - top) <= SETTLE_PX && + abs(it.blueness - finalBlueness) <= SETTLE_COLOR && + abs(it.scrimRed - finalScrimRed) <= SETTLE_COLOR + } + return settled.firstOrNull()?.tMs + } + + /** How much darker the scrim got between the dialog's first frame and the end. */ + val scrimRamp: Int + get() { + val first = visible.firstOrNull()?.scrimRed ?: return 0 + return first - finalScrimRed + } + + fun table(): String = + buildString { + appendLine(" t(ms) scrimR top bottom blueness (hide requested at ${hideAtMs}ms)") + for (s in all) { + appendLine( + " %5d %6d %4s %6s %8d".format( + s.tMs, + s.scrimRed, + s.dialogTop?.toString() ?: "-", + s.dialogBottom?.toString() ?: "-", + s.blueness, + ), + ) + } + } + + fun summary(): String = + "show: firstVisible=${firstVisibleMs}ms settled=${settledMs}ms animated=${animationMs}ms " + + "slideIn=${slideInPx}px " + + "scrimRamp=$scrimRamp finalScrimRed=$finalScrimRed finalBlueness=$finalBlueness " + + "stalls=$showStalls | hide: start=${hideStartMs}ms gone=${hideGoneMs}ms " + + "minHeight=${hideMinHeightRatio?.let { "%.2f".format(it) }} stalls=$hideStalls" + } + + /** Keyed by (material, native). */ + private val measured = HashMap, Curve>() + private val measuredTranslated = HashMap() + private val dialogShown = mutableStateOf(false) + private val translatedShown = mutableStateOf(false) + + @Composable + private fun Content() { + // Enough text under the dialog for the owner window's frame to cost + // something: a scrim fade re-presents the owner every frame, and a + // trivial scene would hide a cadence problem a real app shows. + androidx.compose.foundation.layout.Column(Modifier.fillMaxSize().background(Color.White)) { + repeat(HEAVY_ROWS) { row -> + androidx.compose.material.Text( + text = "Row $row - " + "lorem ipsum dolor sit amet ".repeat(HEAVY_REPEATS), + color = Color.DarkGray, + maxLines = 1, + ) + } + } + val shown by dialogShown + if (shown) { + Dialog(onDismissRequest = { }) { + Box(Modifier.size(DIALOG_W_DP.dp, DIALOG_H_DP.dp).background(DIALOG_COLOR)) + } + } + } + + /** + * The dialog nucleus-demo's Containment gallery opens: a Material 3 + * `AlertDialog` — `Surface` with shape, tonal and shadow elevation, title, + * body text and two text buttons — under a Material 3 theme. The container + * is painted [DIALOG_COLOR] so the sampler finds it the same way. + */ + @Composable + private fun MaterialContent() { + androidx.compose.material3.MaterialTheme { + androidx.compose.foundation.layout.Column(Modifier.fillMaxSize().background(Color.White)) { + repeat(HEAVY_ROWS) { row -> + androidx.compose.material3.Text( + text = "Row $row - " + "lorem ipsum dolor sit amet ".repeat(HEAVY_REPEATS), + color = Color.DarkGray, + maxLines = 1, + ) + } + } + val shown by dialogShown + if (shown) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { }, + containerColor = DIALOG_COLOR, + titleContentColor = Color.White, + textContentColor = Color.White, + title = { androidx.compose.material3.Text("What is a dialog?") }, + text = { + androidx.compose.material3.Text( + "A dialog is a type of modal window that appears in front of app content " + + "to provide critical information, or prompt for a decision to be made.", + ) + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { }) { androidx.compose.material3.Text("Okay") } + }, + dismissButton = { + androidx.compose.material3.TextButton( + onClick = { }, + ) { androidx.compose.material3.Text("Dismiss") } + }, + ) + } + } + } + + /** A popup whose content is moved by a plain graphicsLayer translation, no animation. */ + @Composable + private fun TranslatedContent() { + Box(Modifier.fillMaxSize().background(Color.White)) + val shown by translatedShown + // Exactly what Dialog.skiko.kt does: a GraphicsLayer created from the + // *owner window's* GraphicsContext, recorded and drawn inside the layer. + val graphicsContext = androidx.compose.ui.platform.LocalGraphicsContext.current + val layer = androidx.compose.runtime.remember { graphicsContext.createGraphicsLayer() } + if (shown) { + androidx.compose.ui.window.Popup(alignment = androidx.compose.ui.Alignment.Center) { + Box( + Modifier + .size(DIALOG_W_DP.dp, DIALOG_H_DP.dp) + .drawWithContent { + layer.record { this@drawWithContent.drawContent() } + layer.translationY = STATIC_TRANSLATION_PX + layer.scaleX = 0.95f + layer.scaleY = 0.95f + // Half-transparent like a dialog mid-appearance: alpha + // switches the GraphicsLayer to its saveLayer path. + layer.alpha = 0.5f + drawLayer(layer) + }.background(DIALOG_COLOR), + ) + } + } + } + + private fun translated(native: Boolean): TaoWindowTestCase = + TaoWindowTestCase( + name = "graphicsLayer translation filmed — ${if (native) "native popup layer" else "in-scene layer"}", + skip = ::skipReason, + nativePopupLayers = native, + content = { TranslatedContent() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + window.setAlwaysOnTop(true) + window.focus() + settle(SETTLE_BEFORE_MILLIS) + val rect = requireNotNull(bounds()) { "window not mapped" } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val region = + Rectangle( + (rect[0] / scale).roundToInt(), + (rect[1] / scale).roundToInt(), + (rect[2] / scale).roundToInt(), + (rect[3] / scale).roundToInt(), + ) + translatedShown.value = true + try { + settle(SETTLE_BEFORE_MILLIS) + val img = Robot().createScreenCapture(region) + val s = sample(0, img) + measuredTranslated[native] = s + System.err.println( + "[dialog-appearance] translated ${if (native) "native" else "in-scene"}: " + + "top=${s.dialogTop} bottom=${s.dialogBottom} blueness=${s.blueness}", + ) + check(s.dialogTop != null) { "the translated popup never showed up on screen" } + } finally { + translatedShown.value = false + } + } + + private fun compareTranslated(): TaoWindowTestCase = + TaoWindowTestCase( + name = "graphicsLayer translation — native popup layer lands where the in-scene one does", + skip = { skipReason() ?: if (measuredTranslated.size < 2) "both filming cases must run first" else null }, + content = { TranslatedContent() }, + ) { + val a = requireNotNull(measuredTranslated[false]) + val b = requireNotNull(measuredTranslated[true]) + check( + abs(a.dialogTop!! - b.dialogTop!!) <= SLIDE_TOLERANCE_PX && + abs(a.dialogBottom!! - b.dialogBottom!!) <= SLIDE_TOLERANCE_PX, + ) { + "translated content lands elsewhere in a native layer: " + + "in-scene top=${a.dialogTop} bottom=${a.dialogBottom} " + + "native top=${b.dialogTop} bottom=${b.dialogBottom}" + } + } + + private fun film( + native: Boolean, + material: Boolean = false, + ): TaoWindowTestCase = + TaoWindowTestCase( + name = + "${if (material) "Material 3 AlertDialog" else "dialog"} appearance filmed — " + + "${if (native) "native popup layer" else "in-scene layer"}", + skip = ::skipReason, + nativePopupLayers = native, + content = { if (material) MaterialContent() else Content() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + // The screen grab sees whatever is on top; the suite's window is not. + window.setAlwaysOnTop(true) + window.focus() + settle(SETTLE_BEFORE_MILLIS) + val rect = requireNotNull(bounds()) { "window not mapped" } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + // Robot speaks logical screen points; the window reports physical px. + val region = + Rectangle( + (rect[0] / scale).roundToInt(), + (rect[1] / scale).roundToInt(), + (rect[2] / scale).roundToInt(), + (rect[3] / scale).roundToInt(), + ) + val robot = Robot() + val frames = java.util.Collections.synchronizedList(mutableListOf>()) + val capturing = + java.util.concurrent.atomic + .AtomicBoolean(true) + val grabber = + kotlin.concurrent.thread(name = "dialog-appearance-capture") { + while (capturing.get() && frames.size < MAX_FRAMES) { + frames += System.nanoTime() to robot.createScreenCapture(region) + } + } + // Warm-up: the first composition of a dialog loads fonts and theme + // tokens; that would be filmed as a slow appearance. + dialogShown.value = true + settle(SETTLE_BEFORE_MILLIS) + dialogShown.value = false + settle(SETTLE_BEFORE_MILLIS) + settle(WARMUP_MILLIS) + val shownNs = System.nanoTime() + dialogShown.value = true + var hiddenNs = Long.MAX_VALUE + try { + settle(FILM_MILLIS) + hiddenNs = System.nanoTime() + dialogShown.value = false + settle(HIDE_FILM_MILLIS) + } finally { + capturing.set(false) + grabber.join() + dialogShown.value = false + } + settle(SETTLE_BEFORE_MILLIS) + val curve = + Curve( + frames + .filter { (ns, _) -> ns >= shownNs } + .map { (ns, img) -> sample((ns - shownNs) / 1_000_000, img) }, + hideAtMs = (hiddenNs - shownNs) / 1_000_000, + ) + measured[material to native] = curve + val mode = (if (material) "m3-" else "") + if (native) "native" else "in-scene" + // Keep the first and last grabbed frames on disk: when a curve reads + // wrong, the pictures say whether the region or the dialog is off. + val dir = java.io.File(System.getProperty("java.io.tmpdir"), "dialog-appearance").apply { mkdirs() } + frames.firstOrNull()?.let { + javax.imageio.ImageIO.write( + it.second, + "png", + java.io.File(dir, "$mode-first.png"), + ) + } + frames.lastOrNull()?.let { + javax.imageio.ImageIO.write( + it.second, + "png", + java.io.File(dir, "$mode-last.png"), + ) + } + if (System.getProperty("nucleus.dialog.appearance.dump") == "true") { + for ((ns, img) in frames) { + val t = (ns - shownNs) / 1_000_000 + if (t in + 0..DUMP_UNTIL_MS + ) { + javax.imageio.ImageIO.write(img, "png", java.io.File(dir, "$mode-t%03d.png".format(t))) + } + } + } + val screen = + java.awt.GraphicsEnvironment + .getLocalGraphicsEnvironment() + .defaultScreenDevice.defaultConfiguration + System.err.println( + "[dialog-appearance] $mode: window=${rect.toList()} scale=$scale region=$region " + + "awtScreen=${screen.bounds} awtTransform=${screen.defaultTransform.scaleX} " + + "frames=${frames.size} dump=$dir", + ) + System.err.println("[dialog-appearance] $mode: ${curve.summary()}") + System.err.print(curve.table()) + check(curve.firstVisibleMs != null) { "the dialog never showed up on screen; ${curve.summary()}" } + } + + private fun compare(material: Boolean = false): TaoWindowTestCase = + TaoWindowTestCase( + name = + "${if (material) "Material 3 AlertDialog" else "dialog"} appearance — " + + "native popup layer matches the in-scene layer", + skip = { + skipReason() + ?: if (measured[material to false] == null || measured[material to true] == null) { + "both filming cases must run first" + } else { + null + } + }, + content = { Content() }, + ) { + val inScene = requireNotNull(measured[material to false]) + val native = requireNotNull(measured[material to true]) + System.err.println("[dialog-appearance] in-scene: ${inScene.summary()}") + System.err.println("[dialog-appearance] native: ${native.summary()}") + val problems = mutableListOf() + + fun near( + what: String, + a: Number?, + b: Number?, + tolerance: Number, + ) { + if (a == null || b == null) { + problems += "$what: in-scene=$a native=$b" + } else if (abs(a.toDouble() - b.toDouble()) > tolerance.toDouble()) { + problems += "$what: in-scene=$a native=$b (tolerance $tolerance)" + } + } + // One-sided: the native layer shows its first frame sooner (its + // surface presents without waiting for the owner's frame); later + // than the in-scene layer would be a regression. + val inSceneFirst = inScene.firstVisibleMs + val nativeFirst = native.firstVisibleMs + if (inSceneFirst == null || + nativeFirst == null || + nativeFirst > inSceneFirst + FIRST_VISIBLE_TOLERANCE_MS + ) { + problems += + "first visible (ms): in-scene=$inSceneFirst native=$nativeFirst (tolerance $FIRST_VISIBLE_TOLERANCE_MS)" + } + near("appearance duration (ms)", inScene.animationMs, native.animationMs, SETTLE_TOLERANCE_MS) + near("slide-in (px)", inScene.slideInPx, native.slideInPx, SLIDE_TOLERANCE_PX) + near("scrim ramp", inScene.scrimRamp, native.scrimRamp, COLOR_TOLERANCE) + near("final scrim", inScene.finalScrimRed, native.finalScrimRed, COLOR_TOLERANCE) + near("final content", inScene.finalBlueness, native.finalBlueness, COLOR_TOLERANCE) + near("hide start (ms)", inScene.hideStartMs, native.hideStartMs, FIRST_VISIBLE_TOLERANCE_MS) + near("hide gone (ms)", inScene.hideGoneMs, native.hideGoneMs, SETTLE_TOLERANCE_MS) + near("hide min height ratio", inScene.hideMinHeightRatio, native.hideMinHeightRatio, HEIGHT_RATIO_TOLERANCE) + if (native.showStalls > inScene.showStalls + STALL_TOLERANCE) { + problems += + "appearance drops frames: in-scene stalls=${inScene.showStalls} native stalls=${native.showStalls}" + } + if (native.hideStalls > inScene.hideStalls + STALL_TOLERANCE) { + problems += + "disappearance drops frames: in-scene stalls=${inScene.hideStalls} native stalls=${native.hideStalls}" + } + check(problems.isEmpty()) { + "the native popup layer's dialog does not appear like the in-scene one:\n " + + problems.joinToString("\n ") + } + } + + /** Reads one grabbed frame; coordinates are logical px inside the window's outer rect. */ + private fun sample( + tMs: Long, + img: BufferedImage, + ): Sample { + val w = img.width + val h = img.height + val scrim = img.getRGB(SCRIM_PROBE_INSET, h - SCRIM_PROBE_INSET) + val x = w / 2 + var top: Int? = null + var bottom: Int? = null + for (y in 0 until h) { + if (isDialogColor(img.getRGB(x, y))) { + if (top == null) top = y + bottom = y + } + } + val blueness = + if (top != null && bottom != null) { + val c = img.getRGB(x, (top + bottom) / 2) + blue(c) - red(c) + } else { + 0 + } + return Sample(tMs, red(scrim), top, bottom, blueness) + } + + /** Anything the dialog's blue could look like while fading in over the scrimmed white. */ + private fun isDialogColor(argb: Int): Boolean = blue(argb) - red(argb) > DIALOG_DETECT_THRESHOLD + + private fun red(argb: Int): Int = (argb shr 16) and 0xFF + + private fun blue(argb: Int): Int = argb and 0xFF + + private fun skipReason(): String? = + if (java.awt.GraphicsEnvironment.isHeadless()) "no display for Robot capture" else null + + private val DIALOG_COLOR = Color(0xFF1030C0) + private const val DIALOG_W_DP = 320 + private const val DIALOG_H_DP = 220 + private const val STATIC_TRANSLATION_PX = 40f + private const val WHITE = 255 + private const val SCRIM_PROBE_INSET = 16 + private const val DIALOG_DETECT_THRESHOLD = 40 + private const val SETTLE_BEFORE_MILLIS = 600L + private const val WARMUP_MILLIS = 200L + private const val FILM_MILLIS = 700L + private const val HIDE_FILM_MILLIS = 500L + private const val HEAVY_ROWS = 40 + private const val HEAVY_REPEATS = 6 + private const val STALL_TOLERANCE = 3 + private const val HEIGHT_RATIO_TOLERANCE = 0.15f + private const val MAX_FRAMES = 200 + private const val DUMP_UNTIL_MS = 1_300L + private const val SETTLE_PX = 1 + private const val SETTLE_COLOR = 6 + private const val FIRST_VISIBLE_TOLERANCE_MS = 50L + private const val SETTLE_TOLERANCE_MS = 80L + private const val SLIDE_TOLERANCE_PX = 4 + private const val COLOR_TOLERANCE = 20 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt new file mode 100644 index 000000000..010c08f44 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt @@ -0,0 +1,723 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.Popup +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoMonitors +import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge +import dev.nucleusframework.window.tao.ffi.PopupNativeBridgeWindows +import dev.nucleusframework.window.tao.popup.PopupFrameRecord +import dev.nucleusframework.window.tao.popup.TaoPopupDiagnostics +import kotlinx.coroutines.delay +import kotlin.math.abs + +/** + * Headful battery for issue #569 — "nativePopupLayers: popups position against + * the window, not the screen". + * + * With `nativePopupLayers = true` a Compose `Popup` becomes a real OS window + * that escapes the owner. Native placement was already correct; the *decision* + * was not, in two compounding ways: + * + * 1. The layers built a work-area-sized `WindowInfo` so popups could lay out + * and flip against the display — but then `setContent` replayed the parent + * window's composition locals *over* it, so `Popup.skiko.kt` read the owner + * window's `containerSize` and clipped every popup back into the window. + * 2. Even with the work area in force, that box is rooted at the window's + * content top-left, not at the work-area origin. A `DropdownMenu` in a + * window near the bottom of the display did not flip up — Compose believed + * a whole work area of room was left below the anchor — and walked off the + * screen. + * + * A `Dialog` goes through the same layer machinery but must *not* follow the + * display: `Dialog.skiko.kt` centres it in `containerSize`, so it keeps the + * owner window as its box (cases 12-13). + * + * Each case places a real window at a real position, opens a popup, and asserts + * against [TaoPopupDiagnostics] — the frame the layer actually pushed, in global + * screen pixels. `boundsInWindow` cannot answer "did the popup land on screen": + * it is deliberately left unclamped, because Compose's own hit-testing is + * expressed in it. + * + * The two halves matter equally. A clamp is only correct if it also *doesn't* + * fire — cases 1, 5, 9 and 12 fail if the fix over-reaches and starts treating + * the owner window (or the display) as everyone's reference rect. + * + * Skipped on native Wayland: a popup there is a `wl_subsurface` positioned + * relative to the parent surface, with no global position to clamp against, so + * the Linux host reports no screen geometry at all. + */ +internal object NativePopupPlacementHeadfulCases { + fun all(): List = + listOf( + popupInsideIsNotMoved(), + popupAtBottomEdgeIsClamped(), + popupAtRightEdgeIsClamped(), + popupAtBottomRightCornerIsClamped(), + popupEscapesTheOwnerWindowWhenTheScreenHasRoom(), + popupAboveScreenTopIsClamped(), + oversizedPopupKeepsItsTopLeft(), + dropdownMenuAtBottomEdgeStaysOnScreen(), + popupLargerThanItsOwnerWindowStaysOnScreen(), + ownerMoveReclampsAnOpenPopup(), + nativeWindowRectMatchesTheClampedFrame(), + dialogStaysCentredInItsWindow(), + dialogNearTheScreenEdgeIsStillClamped(), + dialogSurfaceCoversItsShadow(), + ) + + // ── 1. no gratuitous shifting ───────────────────────────────────────── + + private fun popupInsideIsNotMoved(): TaoWindowTestCase = + popupCase("#569 a popup with room around it is placed exactly where Compose asked") { + centerWindow() + val record = openPopup(offset = IntOffset(POPUP_INSET_PX, POPUP_INSET_PX)) + checkOnWorkArea(record) + check(record.clampOffsetPx == IntOffset.Zero) { + "a popup with room on every side must not be moved, got ${record.clampOffsetPx}" + } + } + + // ── 2-4. the reported failure ───────────────────────────────────────── + // + // The offsets matter. `Popup(alignment)` aligns inside the *parent* scene + // (the owner window's content), so an alignment alone never leaves the + // window and never reproduces #569. The extra offset is what pushes the + // popup past the window edge — where `Popup.skiko.kt` happily allows it, + // because its clip box is the work-area-sized virtual screen rooted at the + // window, and only there does the missing screen origin show up. + + private fun popupAtBottomEdgeIsClamped(): TaoWindowTestCase = + popupCase("#569 a popup anchored past the bottom of the work area slides back in") { + moveWindow(fromBottomPx = edgeMarginPx()) + val record = openPopup(alignment = Alignment.BottomStart, offset = IntOffset(0, POPUP_H_DP)) + checkOnWorkArea(record) + check(record.clampOffsetPx.y < 0) { + "expected an upward clamp at the bottom edge; ${describe(record)}" + } + } + + private fun popupAtRightEdgeIsClamped(): TaoWindowTestCase = + popupCase("#569 a popup anchored past the right of the work area slides back in") { + moveWindow(fromRightPx = edgeMarginPx()) + val record = openPopup(alignment = Alignment.TopEnd, offset = IntOffset(POPUP_W_DP, 0)) + checkOnWorkArea(record) + check(record.clampOffsetPx.x < 0) { + "expected a leftward clamp at the right edge; ${describe(record)}" + } + } + + private fun popupAtBottomRightCornerIsClamped(): TaoWindowTestCase = + popupCase("#569 a popup in the bottom-right corner clamps on both axes") { + moveWindow(fromBottomPx = edgeMarginPx(), fromRightPx = edgeMarginPx()) + val record = + openPopup( + alignment = Alignment.BottomEnd, + offset = IntOffset(POPUP_W_DP, POPUP_H_DP), + ) + checkOnWorkArea(record) + check(record.clampOffsetPx.x < 0 && record.clampOffsetPx.y < 0) { + "expected both axes to clamp in the corner; ${describe(record)}" + } + } + + // ── 5-6. the window edge is not a screen edge ───────────────────────── + + private fun popupEscapesTheOwnerWindowWhenTheScreenHasRoom(): TaoWindowTestCase = + popupCase("#569 a popup outside the owner window is left alone while the screen has room") { + centerWindow() + val windowRight = windowRightPx() + // Offset past the window's own right edge. The whole point of + // native popup layers is that a popup may leave the window; a + // clamp that used the window as its reference rect (the pre-#569 + // behaviour, only from the other side) would drag it back in. + val record = openPopup(offset = IntOffset(windowWidthDp() + POPUP_ESCAPE_DP, 0)) + checkOnWorkArea(record) + check(record.contentOnScreenPx.left > windowRight) { + "popup must be allowed outside the owner window: " + + "content=${record.contentOnScreenPx} windowRight=$windowRight" + } + check(record.clampOffsetPx == IntOffset.Zero) { + "nothing to clamp here — the popup is off the window, not off the screen; " + + describe(record) + } + } + + private fun popupAboveScreenTopIsClamped(): TaoWindowTestCase = + popupCase("#569 a popup above the top of the work area slides down") { + // Compose clips popup positions at 0 in *window* coordinates, so a + // popup can only end up above the work area when the window itself + // does. Drag the window's top off the top of the screen — the + // everyday way a user gets there. + moveWindow(abovePx = ABOVE_SCREEN_PX) + val record = openPopup() + checkOnWorkArea(record) + check(record.clampOffsetPx.y > 0) { + "expected a downward clamp above the work area; ${describe(record)}" + } + } + + // ── 7. oversized ────────────────────────────────────────────────────── + + private fun oversizedPopupKeepsItsTopLeft(): TaoWindowTestCase = + popupCase("#569 a popup taller than the work area is aligned to the work-area top") { + centerWindow() + val work = workArea() + val tallDp = (work.height / scale()).toInt() + OVERSIZE_SLACK_DP + val record = openPopup(heightDp = tallDp) + val frame = record.contentOnScreenPx + // It cannot fit; the contract is that the *top* stays visible (a + // menu's first items, a tooltip's first line). + check(frame.top == work.top) { + "an oversized popup must align to the work-area top: frame=$frame work=$work" + } + check(frame.left >= work.left) { + "left edge escaped the work area: frame=$frame work=$work" + } + } + + // ── 8. the component the issue names ────────────────────────────────── + + private fun dropdownMenuAtBottomEdgeStaysOnScreen(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a DropdownMenu near the bottom of the display stays on screen", + skip = ::skipReason, + nativePopupLayers = true, + content = { DropdownSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + moveWindow(fromBottomPx = edgeMarginPx()) + TaoPopupDiagnostics.reset() + dropdownExpanded.value = true + try { + checkOnWorkArea(awaitSettledRecord()) + } finally { + dropdownExpanded.value = false + } + } + + // ── 9. the tray-anchor pattern ──────────────────────────────────────── + + private fun popupLargerThanItsOwnerWindowStaysOnScreen(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a popup far larger than its owner window is placed against the display", + skip = ::skipReason, + nativePopupLayers = true, + size = DpSize(TINY_WINDOW_DP.dp, TINY_WINDOW_DP.dp), + content = { PopupSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + moveWindow(fromBottomPx = edgeMarginPx(), fromRightPx = edgeMarginPx()) + val record = + openPopup( + widthDp = POPUP_W_DP * 2, + heightDp = POPUP_H_DP * 2, + ) + checkOnWorkArea(record) + // The owner is TINY_WINDOW_DP square and the popup many times that + // — the shape #569 broke worst, since the clamp reference used to + // be a work-area-sized box rooted at this tiny window. + val minWidthPx = (POPUP_W_DP * scale()).toInt() + check(record.contentOnScreenPx.width >= minWidthPx) { + "popup collapsed toward the owner window size: ${record.contentOnScreenPx}" + } + } + + // ── 10. re-clamp on owner move ──────────────────────────────────────── + + private fun ownerMoveReclampsAnOpenPopup(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 moving the owner window re-clamps an already-open popup", + skip = { + // macOS panels are AppKit child windows that ride along with + // the owner; there is no owner-move re-clamp there by design + // (documented on TaoPopupSceneLayer). + skipReason() ?: "no owner-move re-clamp on macOS".takeIf { Platform.Current == Platform.MacOS } + }, + nativePopupLayers = true, + content = { PopupSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + centerWindow() + val opened = + openPopup( + alignment = Alignment.BottomStart, + offset = IntOffset(0, POPUP_H_DP), + closeAfter = false, + ) + try { + check(opened.clampOffsetPx == IntOffset.Zero) { + "popup should open unclamped in the middle of the screen, got ${opened.clampOffsetPx}" + } + // Move the window into the bottom-right corner with the popup + // still open: the owner-move listener must re-issue the frame. + TaoPopupDiagnostics.reset() + moveWindow(fromBottomPx = edgeMarginPx(), fromRightPx = edgeMarginPx()) + awaitUntil( + "popup re-clamped after the owner moved", + detail = { "last=${TaoPopupDiagnostics.lastFrame?.frameOnScreenPx}" }, + ) { + TaoPopupDiagnostics.lastFrame?.clampOffsetPx?.let { it != IntOffset.Zero } == true + } + checkOnWorkArea(requireNotNull(TaoPopupDiagnostics.lastFrame)) + } finally { + popupRequest.value = null + } + } + + // ── 11. the OS agrees ───────────────────────────────────────────────── + + private fun nativeWindowRectMatchesTheClampedFrame(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 the popup window's real screen rect is the clamped one", + // Reads the popup's own HWND back through Win32. The equivalent + // introspection has no counterpart for a bare NSPanel handle or a + // Tao popup window here, so the round-trip is Windows-only; the + // other platforms are covered by the frame assertions above. + skip = { skipReason() ?: "Windows only".takeIf { Platform.Current != Platform.Windows } }, + nativePopupLayers = true, + content = { PopupSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + moveWindow(fromBottomPx = edgeMarginPx()) + val record = + openPopup( + alignment = Alignment.BottomStart, + offset = IntOffset(0, POPUP_H_DP), + closeAfter = false, + ) + try { + checkOnWorkArea(record) + val popupHwnd = PopupNativeBridgeWindows.nativeContentHwnd(record.panelHandle) + check(popupHwnd != 0L) { "popup HWND not resolvable from panel=${record.panelHandle}" } + val rect = + requireNotNull(NativeTaoWindowsDecoBridge.nativeGetWindowRect(popupHwnd)) { + "GetWindowRect failed for the popup HWND" + } + val actual = + IntRect( + left = rect[0].toInt(), + top = rect[1].toInt(), + right = (rect[0] + rect[2]).toInt(), + bottom = (rect[1] + rect[3]).toInt(), + ) + // To the pixel: this is what proves the Kotlin-side clamp and + // the native ClientToScreen path neither double-apply nor + // cancel the offset. + check(actual == record.frameOnScreenPx) { + "OS rect $actual disagrees with the reported frame ${record.frameOnScreenPx}" + } + // The surface carries the draw margin past the content, so the + // OS rect may hang off the work area — the content must not. + val work = workArea() + val content = + record.contentOnScreenPx.translate( + IntOffset(actual.left - record.frameOnScreenPx.left, actual.top - record.frameOnScreenPx.top), + ) + check(content.top >= work.top && content.bottom <= work.bottom) { + "the OS placed the popup outside the work area: $content vs $work" + } + } finally { + popupRequest.value = null + } + } + + // ── 12-13. dialogs belong to the window, not the display ────────────── + + private fun dialogStaysCentredInItsWindow(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a Dialog stays centred in its window, not on the display", + skip = ::skipReason, + nativePopupLayers = true, + content = { DialogSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + // Deliberately *not* centred and not maximized: a layer that used + // the work area as every layer's container would centre the dialog + // on the display, which only coincides with the window centre for + // a maximized window on the primary display. + moveWindow(fromRightPx = edgeMarginPx() * DIALOG_WINDOW_INSET_FACTOR) + TaoPopupDiagnostics.reset() + dialogShown.value = true + try { + val record = awaitSettledRecord() + // The content, not the surface: the dialog's appearance animation + // inflates the surface below the layout bounds. + val frame = record.contentOnScreenPx + val rect = requireNotNull(bounds()) { "window not mapped" } + val windowCentreX = (rect[0] + rect[2] / 2).toInt() + val windowCentreY = (rect[1] + rect[3] / 2).toInt() + val dx = abs(frame.left + frame.width / 2 - windowCentreX) + val dy = abs(frame.top + frame.height / 2 - windowCentreY) + // Tolerance covers the decoration inset between the window's + // outer rect (what `bounds()` reports) and its content rect + // (what the dialog centres in). + check(dx <= DIALOG_CENTRE_TOLERANCE_PX && dy <= DIALOG_CENTRE_TOLERANCE_PX) { + "dialog is not centred in its window: frame=$frame " + + "windowCentre=($windowCentreX, $windowCentreY) off by ($dx, $dy)" + } + check(record.clampOffsetPx == IntOffset.Zero) { + "a dialog inside its window needs no clamp; ${describe(record)}" + } + } finally { + dialogShown.value = false + } + } + + private fun dialogSurfaceCoversItsShadow(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a Dialog's surface is inflated around the shadow it draws", + skip = ::skipReason, + nativePopupLayers = true, + content = { DialogSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + centerWindow() + TaoPopupDiagnostics.reset() + dialogShadow.value = true + dialogShown.value = true + try { + val record = awaitSettledRecord() + val frame = record.frameOnScreenPx + val content = record.contentOnScreenPx + // The layout bounds are the content; an in-scene layer draws its + // elevation shadow past them into the window canvas, and a + // separate OS surface must grow to hold it or clip it away. + val coversEverySide = + frame.left < content.left && + frame.top < content.top && + frame.right > content.right && + frame.bottom > content.bottom + check(coversEverySide) { + "the surface must extend past the content on every side to hold the shadow: " + + "frame=$frame content=$content" + } + check(record.boundsInWindowPx.size == content.size) { + "the content frame must keep Compose's layout size; ${describe(record)}" + } + } finally { + dialogShown.value = false + dialogShadow.value = false + } + } + + private fun dialogNearTheScreenEdgeIsStillClamped(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a Dialog whose window hangs off the display is clamped back on", + skip = ::skipReason, + nativePopupLayers = true, + content = { DialogSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + // Window dragged off the top of the screen: centring in the window + // is the right rule, but a dialog nobody can see is not — the same + // clamp that saves popups applies. + moveWindow(abovePx = ABOVE_SCREEN_PX * DIALOG_ABOVE_FACTOR) + TaoPopupDiagnostics.reset() + dialogShown.value = true + try { + val record = awaitSettledRecord() + checkOnWorkArea(record) + check(record.clampOffsetPx.y > 0) { + "expected the dialog to be pushed back onto the display; ${describe(record)}" + } + } finally { + dialogShown.value = false + } + } + + // ── Case scaffolding ────────────────────────────────────────────────── + + /** + * Popup geometry the *driver* chooses, after the window has been placed. + * + * Deliberately not a `LaunchedEffect(delay)`: #569 is about the position + * decided at open time, so a case that opens the popup on a timer while the + * window is still moving would be racing its own setup. One shared slot + * across cases is safe — the harness runs them sequentially in a fresh + * window each time. + */ + private class PopupRequest( + val widthDp: Int, + val heightDp: Int, + val offset: IntOffset, + val alignment: Alignment, + ) + + private val popupRequest = mutableStateOf(null) + private val dropdownExpanded = mutableStateOf(false) + private val dialogShown = mutableStateOf(false) + private val dialogShadow = mutableStateOf(false) + + @Composable + private fun PopupSlot() { + val request by popupRequest + val current = request ?: return + Popup(alignment = current.alignment, offset = current.offset) { + Box(Modifier.size(current.widthDp.dp, current.heightDp.dp).background(Color.Magenta)) + } + } + + /** + * A real `DropdownMenu` anchored at the **bottom** of the window content — + * the everyday shape of #569. Compose opens a dropdown below its anchor and + * only flips when the anchor is near the bottom of what it thinks the + * screen is; anchored here, in a window sitting at the bottom of the + * display, its window-rooted view of the screen sends the menu off it. + */ + @Composable + private fun DropdownSlot() { + val expanded by dropdownExpanded + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { + Box(Modifier.size(DROPDOWN_ANCHOR_DP.dp)) { + DropdownMenu(expanded = expanded, onDismissRequest = { }) { + repeat(DROPDOWN_ITEMS) { index -> + DropdownMenuItem(onClick = { }) { Text("item $index") } + } + } + } + } + } + + /** + * A `Dialog` — the other thing that lands in a scene layer, and the one + * that must *not* be placed against the display. `Dialog.skiko.kt` puts it + * at `containerSize.center`, so a layer reporting the work area as its + * container would centre a window-owned dialog on the screen instead of on + * its window. + */ + @Composable + private fun DialogSlot() { + val shown by dialogShown + val shadow by dialogShadow + if (shown) { + Dialog(onDismissRequest = { }) { + Box( + Modifier + .size(DIALOG_W_DP.dp, DIALOG_H_DP.dp) + .then(if (shadow) Modifier.shadow(DIALOG_SHADOW_DP.dp) else Modifier) + .background(Color.Cyan), + ) + } + } + } + + private fun popupCase( + name: String, + driver: suspend TaoWindowTestScope.() -> Unit, + ): TaoWindowTestCase = + TaoWindowTestCase( + name = name, + skip = ::skipReason, + nativePopupLayers = true, + content = { PopupSlot() }, + driver = { + awaitUntil("window mapped") { window.hasRealFramePx() } + driver() + }, + ) + + /** Opens the shared [PopupSlot] popup and returns its settled frame. */ + private suspend fun TaoWindowTestScope.openPopup( + widthDp: Int = POPUP_W_DP, + heightDp: Int = POPUP_H_DP, + offset: IntOffset = IntOffset.Zero, + alignment: Alignment = Alignment.TopStart, + closeAfter: Boolean = true, + ): PopupFrameRecord { + TaoPopupDiagnostics.reset() + popupRequest.value = PopupRequest(widthDp, heightDp, offset, alignment) + val record = awaitSettledRecord() + if (closeAfter) popupRequest.value = null + return record + } + + /** + * Waits until the popup layer's pushed frame stops changing. + * + * The layers push a frame from their bootstrap measure pass too (the inner + * scene has to render once before Compose can write `boundsInWindow` at + * all), so the first record can predate the measured size. Settling is what + * makes the assertions about the final position meaningful. + */ + private suspend fun TaoWindowTestScope.awaitSettledRecord(): PopupFrameRecord { + awaitUntil("popup layer pushed a frame") { TaoPopupDiagnostics.lastFrame != null } + var previous: IntRect? = null + var stable = 0 + val deadline = System.currentTimeMillis() + RECORD_SETTLE_TIMEOUT_MILLIS + while (stable < STABLE_FRAMES) { + delay(RECORD_POLL_MILLIS) + val frame = TaoPopupDiagnostics.lastFrame?.frameOnScreenPx + stable = if (frame != null && frame == previous) stable + 1 else 0 + previous = frame + check(System.currentTimeMillis() < deadline) { "popup frame never settled (last=$frame)" } + } + return requireNotNull(TaoPopupDiagnostics.lastFrame) + } + + // ── Assertions ──────────────────────────────────────────────────────── + + /** + * The #569 contract: the popup is fully inside its display's work area. + * Judged on the content — the surface may carry a shadow margin past the + * edge, exactly as an in-scene layer's shadow would. + */ + private fun TaoWindowTestScope.checkOnWorkArea(record: PopupFrameRecord) { + val frame = record.contentOnScreenPx + val areas = TaoMonitors.all(window).map { it.workAreaPx } + check(areas.any { frame.fitsIn(it) }) { + "popup landed outside every work area: content=$frame areas=$areas " + + "clamp=${record.clampOffsetPx} composeBounds=${record.boundsInWindowPx}" + } + } + + /** + * Guards the edge cases against passing for the wrong reason: if the clamp + * agreed with Compose's own decision, the window was not actually placed + * somewhere that reproduces #569 and the case proves nothing. + */ + private fun TaoWindowTestScope.checkClampDiverged(record: PopupFrameRecord) { + check(record.clampOffsetPx != IntOffset.Zero) { + "the clamp never fired — the window is not at an edge, so this case " + + "is not exercising #569 (frame=${record.frameOnScreenPx} " + + "composeBounds=${record.boundsInWindowPx} window=${bounds()?.toList()} " + + "work=${workArea()} scale=${scale()})" + } + } + + private fun TaoWindowTestScope.describe(record: PopupFrameRecord): String = + "frame=${record.frameOnScreenPx} content=${record.contentOnScreenPx} clamp=${record.clampOffsetPx} " + + "composeBounds=${record.boundsInWindowPx} window=${bounds()?.toList()} " + + "work=${workArea()} scale=${scale()}" + + private fun IntRect.fitsIn(other: IntRect): Boolean = + left >= other.left && top >= other.top && right <= other.right && bottom <= other.bottom + + // ── Geometry helpers ────────────────────────────────────────────────── + + private fun TaoWindowTestScope.workArea(): IntRect = TaoMonitors.forWindow(window).workAreaPx + + private fun TaoWindowTestScope.scale(): Float = window.scaleFactor.takeIf { it > 0f } ?: 1f + + /** Margin the edge cases leave between the window and the work-area edge. */ + private fun TaoWindowTestScope.edgeMarginPx(): Int = (EDGE_MARGIN_DP * scale()).toInt() + + private fun TaoWindowTestScope.windowRightPx(): Int { + val rect = requireNotNull(bounds()) { "window not mapped" } + return (rect[0] + rect[2]).toInt() + } + + /** The owner window's width in dp — the unit `Popup(offset =)` takes. */ + private fun TaoWindowTestScope.windowWidthDp(): Int { + val rect = requireNotNull(bounds()) { "window not mapped" } + return (rect[2] / scale()).toInt() + } + + private suspend fun TaoWindowTestScope.centerWindow() { + val work = workArea() + val rect = requireNotNull(bounds()) { "window not mapped" } + moveTo( + work.left + (work.width - rect[2].toInt()) / 2, + work.top + (work.height - rect[3].toInt()) / 2, + ) + } + + /** + * Places the window against a work-area edge — the geometry that makes + * Compose's window-rooted flip decision wrong. Unconstrained axes are + * centred. + */ + private suspend fun TaoWindowTestScope.moveWindow( + fromBottomPx: Int? = null, + fromTopPx: Int? = null, + fromRightPx: Int? = null, + abovePx: Int? = null, + ) { + val work = workArea() + val rect = requireNotNull(bounds()) { "window not mapped" } + val w = rect[2].toInt() + val h = rect[3].toInt() + val x = + if (fromRightPx != null) work.right - w - fromRightPx else work.left + (work.width - w) / 2 + val y = + when { + fromBottomPx != null -> work.bottom - h - fromBottomPx + fromTopPx != null -> work.top + fromTopPx + abovePx != null -> work.top - abovePx + else -> work.top + (work.height - h) / 2 + } + moveTo(x, y) + } + + private suspend fun TaoWindowTestScope.moveTo( + xPx: Int, + yPx: Int, + ) { + window.setOuterPositionPx(xPx, yPx) + awaitUntil( + "window settled at ${xPx}x$yPx", + detail = { "bounds=${bounds()?.toList()}" }, + ) { + val rect = bounds() ?: return@awaitUntil false + abs(rect[0] - xPx) <= MOVE_TOLERANCE_PX && abs(rect[1] - yPx) <= MOVE_TOLERANCE_PX + } + // The owner-move listener runs on the Tao loop; give the layers a frame + // to react before anything reads the popup's frame back. + settle(SETTLE_MILLIS) + } + + private fun skipReason(): String? = + if (Platform.Current == Platform.Linux && isNativeWayland) { + "Wayland popups are parent-relative subsurfaces — no global position to clamp" + } else { + null + } + + private val isNativeWayland: Boolean + get() { + val forcedX11 = + System.getenv("GDK_BACKEND")?.split(',')?.firstOrNull() == "x11" || + System.getenv("NUCLEUS_TAO_LINUX_RENDERER").orEmpty().equals("x11", ignoreCase = true) + return System.getenv("WAYLAND_DISPLAY") != null && !forcedX11 + } + + private const val POPUP_W_DP = 240 + private const val POPUP_H_DP = 200 + private const val POPUP_INSET_PX = 20 + private const val TINY_WINDOW_DP = 120 + private const val EDGE_MARGIN_DP = 40 + private const val OVERSIZE_SLACK_DP = 200 + private const val POPUP_ESCAPE_DP = 24 + private const val ABOVE_SCREEN_PX = 260 + private const val DIALOG_W_DP = 320 + private const val DIALOG_H_DP = 220 + private const val DIALOG_SHADOW_DP = 16 + private const val DIALOG_CENTRE_TOLERANCE_PX = 24 + private const val DIALOG_WINDOW_INSET_FACTOR = 2 + private const val DIALOG_ABOVE_FACTOR = 2 + private const val DROPDOWN_ANCHOR_DP = 60 + private const val DROPDOWN_ITEMS = 12 + private const val SETTLE_MILLIS = 600L + private const val RECORD_POLL_MILLIS = 50L + private const val RECORD_SETTLE_TIMEOUT_MILLIS = 10_000L + private const val STABLE_FRAMES = 4 + private const val MOVE_TOLERANCE_PX = 8L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt index cb6a13ae1..89e3a157b 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt @@ -367,6 +367,8 @@ public object TaoHeadfulTestSuiteMain { FramePacingHeadfulCases.all() + MacWindowChromeStateHeadfulCases.all() + PopupScaleHeadfulCases.all() + + NativePopupPlacementHeadfulCases.all() + + DialogAppearanceHeadfulCases.all() + ClipboardHeadfulCases.all() + AnimatedWindowSizeHeadfulCases.all() + AlwaysOnTopHeadfulCases.all() + diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflateTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflateTest.kt new file mode 100644 index 000000000..23a7e03ea --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflateTest.kt @@ -0,0 +1,45 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntRect +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Unit cases for the draw margin of native popup layers: the surface must + * cover what Compose draws around the layout bounds, on every side. + */ +class PopupDrawInflateTest { + private val bounds = IntRect(100, 200, 300, 400) + + @Test + fun `the margin is 32 dp in physical pixels`() { + assertEquals(32, popupDrawMarginPx(1f)) + assertEquals(64, popupDrawMarginPx(2f)) + assertEquals(40, popupDrawMarginPx(1.25f)) + } + + @Test + fun `a fractional margin rounds up`() { + assertEquals(48, popupDrawMarginPx(1.5f)) + assertEquals(36, popupDrawMarginPx(1.1f)) + } + + @Test + fun `a density below one is treated as one`() { + assertEquals(32, popupDrawMarginPx(0.5f)) + } + + @Test + fun `the surface is inflated on every side`() { + assertEquals(IntRect(68, 168, 332, 432), popupDrawBounds(bounds, 1f)) + assertEquals(IntRect(36, 136, 364, 464), popupDrawBounds(bounds, 2f)) + } + + @Test + fun `the content keeps its size and offset inside the surface`() { + val draw = popupDrawBounds(bounds, 2f) + assertEquals(bounds.size.width + 2 * 64, draw.size.width) + assertEquals(64, bounds.left - draw.left) + assertEquals(64, bounds.top - draw.top) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClampTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClampTest.kt new file mode 100644 index 000000000..2f4a51699 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClampTest.kt @@ -0,0 +1,243 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Unit cases for the #569 screen clamp: the geometry decision the headful + * suite then verifies against real windows. + * + * Coordinates are physical pixels. The fixtures use a 1920×1080 primary + * display with a 40 px taskbar (work area `0,0 → 1920,1040`) and, where + * relevant, a second display to its right. + */ +class PopupScreenClampTest { + // ── No geometry / degenerate input: the pre-#569 behaviour ───────────── + + @Test + fun `no geometry leaves the frame untouched`() { + assertEquals( + IntOffset.Zero, + popupScreenClampOffset(rect(0, 0, 200, 300), geometry = null), + ) + } + + @Test + fun `an empty frame is never moved`() { + // A layer pushes frames before Compose has measured the content. + assertEquals(IntOffset.Zero, clampAt(windowAt(1900, 1000), rect(0, 0, 0, 0))) + assertEquals(IntOffset.Zero, clampAt(windowAt(1900, 1000), rect(0, 0, 200, 0))) + } + + @Test + fun `no usable work area leaves the frame untouched`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(1900, 1000), + workAreasPx = listOf(IntRect(0, 0, 0, 0)), + ) + assertEquals(IntOffset.Zero, popupScreenClampOffset(rect(0, 0, 200, 300), geometry)) + } + + // ── The regression the issue reports ────────────────────────────────── + + @Test + fun `a popup already inside the work area is not moved`() { + // Window at the middle of the screen, dropdown just below its anchor. + assertEquals(IntOffset.Zero, clampAt(windowAt(400, 300), rect(50, 120, 200, 180))) + } + + @Test + fun `a dropdown past the bottom edge slides up instead of landing offscreen`() { + // Window content origin 100 px above the taskbar; Compose believes it + // has `workAreaHeight` of room below the anchor, so it does not flip. + val clamp = clampAt(windowAt(400, 940), rect(0, 20, 200, 300)) + // 940 + 20 + 300 = 1260, work area bottom is 1040 → back by 220. + assertEquals(IntOffset(0, -220), clamp) + assertTrue(screenRect(windowAt(400, 940), rect(0, 20, 200, 300), clamp) in PRIMARY_WORK) + } + + @Test + fun `a menu past the right edge slides left`() { + val clamp = clampAt(windowAt(1700, 200), rect(100, 0, 300, 200)) + // 1700 + 100 + 300 = 2100, work area right is 1920 → back by 180. + assertEquals(IntOffset(-180, 0), clamp) + } + + @Test + fun `both axes clamp independently`() { + val clamp = clampAt(windowAt(1800, 1000), rect(60, 60, 400, 400)) + assertEquals(IntOffset(1920 - 400 - 1860, 1040 - 400 - 1060), clamp) + assertTrue(screenRect(windowAt(1800, 1000), rect(60, 60, 400, 400), clamp) in PRIMARY_WORK) + } + + @Test + fun `a popup extending above the window origin is not pinned at zero`() { + // The other half of #569: Compose clamps to 0 in *window* coordinates, + // so a popup that should open upward gets stuck at the window's top + // edge. In screen space there is room, so the clamp must not move it. + assertEquals(IntOffset.Zero, clampAt(windowAt(600, 500), rect(0, -220, 200, 180))) + } + + @Test + fun `a popup above the screen top slides down`() { + // Same shape, but the window itself is at the top: now it really is + // offscreen and must come back in. + val clamp = clampAt(windowAt(600, 30), rect(0, -220, 200, 180)) + assertEquals(IntOffset(0, 190), clamp) + assertEquals(0, screenRect(windowAt(600, 30), rect(0, -220, 200, 180), clamp).top) + } + + @Test + fun `the taskbar is respected, not just the screen bounds`() { + // 1040..1080 is the taskbar. A frame ending at 1060 must come back to + // 1040 even though it is inside the monitor's full bounds. + val clamp = clampAt(windowAt(0, 900), rect(0, 0, 100, 160)) + assertEquals(IntOffset(0, -20), clamp) + } + + // ── Oversized popups keep their top-left ────────────────────────────── + + @Test + fun `a popup taller than the work area is aligned to the top`() { + val clamp = clampAt(windowAt(100, 200), rect(0, 0, 200, 1400)) + // Top-left wins: the menu's first items stay reachable. + assertEquals(IntOffset(0, -200), clamp) + assertEquals(0, screenRect(windowAt(100, 200), rect(0, 0, 200, 1400), clamp).top) + } + + @Test + fun `a popup wider than the work area is aligned to the left`() { + val clamp = clampAt(windowAt(300, 100), rect(0, 0, 2400, 200)) + assertEquals(IntOffset(-300, 0), clamp) + assertEquals(0, screenRect(windowAt(300, 100), rect(0, 0, 2400, 200), clamp).left) + } + + // ── The window's own coordinate space is never used as a screen ─────── + + @Test + fun `the clamp is independent of the owner window size`() { + // A 1×1 tray anchor and a full-screen window at the same origin must + // clamp identically — the whole point of #569 is that the *window* is + // not the reference rect. + val fromTinyWindow = clampAt(windowAt(1850, 1010), rect(0, 0, 240, 200)) + val fromBigWindow = clampAt(windowAt(1850, 1010), rect(0, 0, 240, 200)) + assertEquals(fromTinyWindow, fromBigWindow) + assertEquals(IntOffset(1920 - 240 - 1850, 1040 - 200 - 1010), fromTinyWindow) + } + + // ── Multi-display ───────────────────────────────────────────────────── + + @Test + fun `a popup on the secondary display clamps to that display's work area`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(2400, 100), + workAreasPx = listOf(PRIMARY_WORK, SECONDARY_WORK), + ) + // 2400 + 1000 = 3400 → past the secondary's right edge (3200). + val clamp = popupScreenClampOffset(rect(1000, 0, 300, 200), geometry) + assertEquals(IntOffset(3200 - 300 - 3400, 0), clamp) + } + + @Test + fun `a popup on the secondary display is not yanked onto the primary`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(2400, 200), + workAreasPx = listOf(PRIMARY_WORK, SECONDARY_WORK), + ) + // Well inside the secondary display: clamping against the primary + // work area (the bug a primary-monitor-only lookup would have) would + // have dragged it back to x < 1920. + assertEquals(IntOffset.Zero, popupScreenClampOffset(rect(100, 100, 300, 200), geometry)) + } + + @Test + fun `a popup that overlaps two displays clamps to the one it covers most`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(1800, 300), + workAreasPx = listOf(PRIMARY_WORK, SECONDARY_WORK), + ) + // 1800 + 40 = 1840 → 80 px on the primary, 220 px on the secondary. + // The secondary wins, and the frame is already inside it after the + // left clamp to 1920. + val frame = rect(40, 0, 300, 200) + val clamp = popupScreenClampOffset(frame, geometry) + assertEquals(IntOffset(1920 - 1840, 0), clamp) + } + + @Test + fun `a fully offscreen popup returns to the owner's display`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(2400, 300), + workAreasPx = listOf(PRIMARY_WORK, SECONDARY_WORK), + ) + // Below every work area — overlaps nothing, so the display hosting the + // owner (the secondary) decides. + val clamp = popupScreenClampOffset(rect(0, 900, 200, 200), geometry) + val landed = screenRect(geometry.parentContentOriginPx, rect(0, 900, 200, 200), clamp) + assertTrue(landed in SECONDARY_WORK, "landed on the wrong display: $landed") + } + + // ── Idempotence: the layers re-clamp on every owner move ────────────── + + @Test + fun `clamping an already-clamped frame is a no-op`() { + val origin = windowAt(1800, 1000) + val frame = rect(60, 60, 400, 400) + val first = clampAt(origin, frame) + val moved = IntRect(frame.left + first.x, frame.top + first.y, frame.right + first.x, frame.bottom + first.y) + assertEquals(IntOffset.Zero, clampAt(origin, moved)) + } + + // ── Fixtures ────────────────────────────────────────────────────────── + + private companion object { + val PRIMARY_WORK = IntRect(0, 0, 1920, 1040) + val SECONDARY_WORK = IntRect(1920, 0, 3200, 1024) + + fun rect( + x: Int, + y: Int, + w: Int, + h: Int, + ) = IntRect(x, y, x + w, y + h) + + fun windowAt( + x: Int, + y: Int, + ) = IntOffset(x, y) + + /** Clamp against the single-display fixture. */ + fun clampAt( + parentOrigin: IntOffset, + frameInParent: IntRect, + ): IntOffset = + popupScreenClampOffset( + frameInParent, + PopupScreenGeometry(parentOrigin, listOf(PRIMARY_WORK)), + ) + + /** Where [frameInParent] lands on screen once [clamp] is applied. */ + fun screenRect( + parentOrigin: IntOffset, + frameInParent: IntRect, + clamp: IntOffset, + ): IntRect = + IntRect( + left = parentOrigin.x + frameInParent.left + clamp.x, + top = parentOrigin.y + frameInParent.top + clamp.y, + right = parentOrigin.x + frameInParent.right + clamp.x, + bottom = parentOrigin.y + frameInParent.bottom + clamp.y, + ) + + operator fun IntRect.contains(inner: IntRect): Boolean = + inner.left >= left && inner.top >= top && inner.right <= right && inner.bottom <= bottom + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistryTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistryTest.kt new file mode 100644 index 000000000..0e16fc92e --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistryTest.kt @@ -0,0 +1,148 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.graphics.Color +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Rect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Unit cases for the dialog-scrim bookkeeping of native popup layers: which + * scrims each surface paints, and how they blend. + */ +class PopupScrimRegistryTest { + private val bottom = Any() + private val middle = Any() + private val top = Any() + + private fun stack(vararg colors: Pair): PopupScrimRegistry = + PopupScrimRegistry(onChanged = {}).apply { + for ((token, color) in colors) register(token) { color } + } + + // ── Bookkeeping ──────────────────────────────────────────────────────── + + @Test + fun `popups without a scrim contribute nothing`() { + val registry = stack(bottom to null, middle to null) + assertEquals(emptyList(), registry.all()) + assertEquals(emptyList(), registry.above(bottom)) + } + + @Test + fun `the owner window paints every scrim bottom-up`() { + val registry = stack(bottom to null, middle to Color.Red, top to Color.Blue) + assertEquals(listOf(Color.Red, Color.Blue), registry.all()) + } + + @Test + fun `a layer paints only the scrims of the layers above it`() { + val registry = stack(bottom to Color.Red, middle to Color.Green, top to Color.Blue) + assertEquals(listOf(Color.Green, Color.Blue), registry.above(bottom)) + assertEquals(listOf(Color.Blue), registry.above(middle)) + assertEquals(emptyList(), registry.above(top)) + } + + @Test + fun `an unknown layer sees no scrim above it`() { + val registry = stack(bottom to Color.Red) + assertEquals(emptyList(), registry.above(Any())) + } + + @Test + fun `a scrim written after registration is read at paint time`() { + var color: Color? = null + val registry = PopupScrimRegistry(onChanged = {}).apply { register(top) { color } } + assertEquals(emptyList(), registry.all()) + color = Color.Black + assertEquals(listOf(Color.Black), registry.all()) + } + + @Test + fun `a scrim change is reported to the host`() { + var changes = 0 + val registry = PopupScrimRegistry(onChanged = { changes++ }) + registry.notifyChanged() + assertEquals(1, changes) + } + + @Test + fun `unregistering removes the layer from every view`() { + val registry = stack(bottom to Color.Red, top to Color.Blue) + registry.unregister(top) + assertEquals(listOf(Color.Red), registry.all()) + assertEquals(emptyList(), registry.above(bottom)) + } + + @Test + fun `re-registering moves a layer to the top of the stack`() { + val registry = stack(bottom to Color.Red, top to Color.Blue) + registry.register(bottom) { Color.Red } + assertEquals(listOf(Color.Blue, Color.Red), registry.all()) + assertEquals(listOf(Color.Red), registry.above(top)) + } + + // ── Painting ─────────────────────────────────────────────────────────── + + private fun paintOnto( + opaqueLeftHalf: Boolean, + paint: (Canvas) -> Unit, + ): Bitmap { + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo(4, 2, ColorType.BGRA_8888, ColorAlphaType.PREMUL)) + Canvas(bitmap).use { canvas -> + canvas.clear(0x00000000) + if (opaqueLeftHalf) { + val white = Paint().apply { color = 0xFFFFFFFF.toInt() } + canvas.drawRect(Rect.makeWH(2f, 2f), white) + } + paint(canvas) + } + return bitmap + } + + private fun alphaAt( + bitmap: Bitmap, + x: Int, + y: Int, + ): Int = (bitmap.getColor(x, y) ushr 24) and 0xFF + + @Test + fun `an opaque owner window is dimmed everywhere`() { + val registry = stack(top to Color(0x80000000)) + val bitmap = + paintOnto(opaqueLeftHalf = true) { + registry.paintAll(it, Rect.makeWH(4f, 2f), transparent = false) + } + assertTrue(alphaAt(bitmap, 0, 0) == 0xFF, "drawn pixels stay opaque") + assertTrue(alphaAt(bitmap, 3, 1) > 0, "the scrim lands on undrawn pixels of an opaque window") + } + + @Test + fun `a per-pixel-transparent surface is dimmed only where it drew`() { + val registry = stack(bottom to null, top to Color(0x80000000)) + val bitmap = + paintOnto(opaqueLeftHalf = true) { + registry.paintAbove(bottom, it, Rect.makeWH(4f, 2f)) + } + assertTrue(alphaAt(bitmap, 0, 0) == 0xFF, "drawn pixels stay opaque") + assertEquals(0, alphaAt(bitmap, 3, 1), "SrcAtop leaves undrawn pixels transparent") + assertTrue((bitmap.getColor(0, 0) and 0xFF) < 0xFF, "drawn pixels are darkened") + } + + @Test + fun `no scrim leaves the surface untouched`() { + val registry = stack(bottom to null) + val bitmap = + paintOnto(opaqueLeftHalf = false) { + registry.paintAll(it, Rect.makeWH(4f, 2f), transparent = false) + } + assertEquals(0, alphaAt(bitmap, 0, 0)) + } +} diff --git a/examples/jewel-demo/src/main/kotlin/jewelsample/Main.kt b/examples/jewel-demo/src/main/kotlin/jewelsample/Main.kt index ec7a0c2f9..49737ca71 100644 --- a/examples/jewel-demo/src/main/kotlin/jewelsample/Main.kt +++ b/examples/jewel-demo/src/main/kotlin/jewelsample/Main.kt @@ -73,6 +73,13 @@ fun main() = position = WindowPosition.Aligned(Alignment.Center), ), minimumSize = DpSize(800.dp, 400.dp), + // Jewel's `LocalPopupRenderer` default delegates to + // `androidx.compose.ui.window.Popup`, so every ListComboBox / + // PopupMenu / tooltip in the showcase becomes a real OS window + // and is placed against the display rather than against this + // window (#569). Park the window at the bottom of the screen + // and open a combo box to see it. + nativePopupLayers = true, onKeyEvent = { keyEvent -> processKeyShortcuts(keyEvent = keyEvent, onNavigateTo = MainViewModel::onNavigateTo) }, diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt index 449f79b52..9891aa0da 100644 --- a/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt @@ -148,13 +148,14 @@ fun main(args: Array) = title = "Nucleus Demo", minimumSize = DpSize(1300.dp, 480.dp), nativeContextMenu = true, + nativePopupLayers = false, ) { CompositionLocalProvider( LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr, ) { val tabs = buildList { - addAll(listOf("Nucleus", "Fill Title", "Gallery", "Taskbar", "Scroll Test")) + addAll(listOf("Nucleus", "Fill Title", "Gallery", "Taskbar", "Scroll Test", "Popups")) add("Notifications (Common)") add("Notifications") add("Launcher") @@ -280,6 +281,7 @@ fun main(args: Array) = } "Taskbar" -> TaskbarProgressScreen(nucleusWindow) "Scroll Test" -> ScrollTestScreen() + "Popups" -> PopupPlacementScreen(nucleusWindow.unsafe.taoWindow) "Notifications" -> { when (Platform.Current) { Platform.MacOS -> NotificationsScreen() diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/PopupPlacementScreen.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/PopupPlacementScreen.kt new file mode 100644 index 000000000..64f3d893a --- /dev/null +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/PopupPlacementScreen.kt @@ -0,0 +1,272 @@ +package com.example.demo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.TaoMonitors +import dev.nucleusframework.window.tao.TaoWindow +import kotlinx.coroutines.delay + +/** + * Demo for issue #569 — popups positioned against the **screen**, not the + * owner window. + * + * `nativePopupLayers = true` makes every Compose `Popup` / `DropdownMenu` a + * real OS window, free to extend past the owner. The catch #569 fixed is that + * Compose decided *where* to put it in window-rooted coordinates: it clipped + * and flipped inside a work-area-sized box hanging off the window's content + * origin, which is only the real screen when the window is maximized on the + * primary display. Anywhere else, a menu anchored near the bottom of a window + * sitting near the bottom of the display walked straight off it. + * + * The screen makes that visible with things a demo can actually show: + * - **park the window against a work-area edge** with one click, + * - **open a menu anchored at that same edge** and watch it stay on screen, + * - **open a dialog** and watch it stay centred on the *window* instead — + * a dialog belongs to its window, and only popups follow the display. + * + * Park the window bottom-right and open the bottom-right menu: the live + * readout shows how little room is left below and to the right, and before the + * fix the menu was drawn under the taskbar or off the right edge entirely. + */ +@Composable +fun PopupPlacementScreen(window: TaoWindow?) { + var windowRect by remember { mutableStateOf(null) } + var workArea by remember { mutableStateOf(null) } + + // Poll rather than listen: the point of the screen is to show the geometry + // the popup layer re-reads on every frame push, including while the user + // drags the window by its title bar. + LaunchedEffect(window) { + while (true) { + windowRect = window?.outerRect() + workArea = window?.let { TaoMonitors.forWindow(it).workAreaPx } + delay(POLL_MILLIS) + } + } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Screen-aware popup placement (#569)", style = MaterialTheme.typography.headlineSmall) + Text( + "Every Popup below is a real OS window (nativePopupLayers = true). " + + "Park this window against a work-area edge, then open the menu anchored at " + + "that edge: it slides back inside the display instead of walking off it.", + style = MaterialTheme.typography.bodyMedium, + ) + + GeometryCard(windowRect, workArea) + + Text("1 — park the window", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ParkButton("↖ top-left", window) { work, _ -> work.left to work.top } + ParkButton("↗ top-right", window) { work, size -> (work.right - size.first) to work.top } + ParkButton("center", window) { work, size -> + (work.left + (work.width - size.first) / 2) to (work.top + (work.height - size.second) / 2) + } + ParkButton("↙ bottom-left", window) { work, size -> work.left to (work.bottom - size.second) } + ParkButton("↘ bottom-right", window) { work, size -> + (work.right - size.first) to (work.bottom - size.second) + } + } + + Text("2 — open a menu anchored at an edge", style = MaterialTheme.typography.titleMedium) + // Anchors pinned to the corners of the *window content*: the geometry + // that used to send a menu offscreen, because Compose measured the room + // below/right of the anchor against a screen rooted at this window. + Box(Modifier.fillMaxWidth().height(ANCHOR_BOX_DP.dp)) { + EdgeMenu("top-left menu", Modifier.align(Alignment.TopStart)) + EdgeMenu("top-right menu", Modifier.align(Alignment.TopEnd)) + EdgeMenu("bottom-left menu", Modifier.align(Alignment.BottomStart)) + EdgeMenu("bottom-right menu", Modifier.align(Alignment.BottomEnd)) + EscapingPopupToggle(Modifier.align(Alignment.Center)) + } + + Text( + "The oversized panel deliberately measures larger than this window — " + + "a popup layer lays out against the work area, so it is not scrolled " + + "down to the window's size, and the clamp keeps it on the display.", + style = MaterialTheme.typography.bodySmall, + ) + + Text("3 — and a dialog is not a popup", style = MaterialTheme.typography.titleMedium) + Text( + "A Dialog goes through the very same native layer, but it belongs to its " + + "window, not to the display: Compose centres it in the container size, so " + + "the layer keeps reporting the window there. Park the window in a corner " + + "and open it — it stays centred on the window, wherever that is.", + style = MaterialTheme.typography.bodyMedium, + ) + CenteredDialogToggle() + } +} + +/** A Material dialog, to show it stays centred on the window (see #569). */ +@Composable +private fun CenteredDialogToggle() { + var shown by remember { mutableStateOf(false) } + Button(onClick = { shown = true }) { Text("open a centred dialog") } + if (shown) { + AlertDialog( + onDismissRequest = { shown = false }, + confirmButton = { Button(onClick = { shown = false }) { Text("close") } }, + title = { Text("Centred on the window") }, + text = { + Text( + "Not on the display — a window-owned dialog that drifted to the " + + "screen centre as you moved the window would be the bug, not the fix.", + ) + }, + ) + } +} + +@Composable +private fun GeometryCard( + windowRect: IntRect?, + workArea: IntRect?, +) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("live geometry (physical px)", style = MaterialTheme.typography.titleSmall) + Text("window outer: ${windowRect?.describe() ?: "—"}") + Text("display work area: ${workArea?.describe() ?: "—"}") + val slack = + if (windowRect != null && workArea != null) { + "${workArea.bottom - windowRect.bottom} px below, " + + "${workArea.right - windowRect.right} px to the right" + } else { + "—" + } + Text("room left on the display: $slack") + Text( + "When that room is smaller than the menu, the clamp is what keeps it visible.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +/** + * Moves the window so [target] — computed from the work area and the window's + * own outer size — becomes its top-left. The dp round-trip is deliberate: + * `setOuterPosition` takes logical units, which is what an app would use. + */ +@Composable +private fun ParkButton( + label: String, + window: TaoWindow?, + target: (work: IntRect, size: Pair) -> Pair, +) { + OutlinedButton( + enabled = window != null, + onClick = { + val w = window ?: return@OutlinedButton + val rect = w.outerRect() ?: return@OutlinedButton + val work = TaoMonitors.forWindow(w).workAreaPx + val (x, y) = target(work, rect.width to rect.height) + val scale = w.scaleFactor.takeIf { it > 0f } ?: 1f + w.setOuterPosition(x / scale.toDouble(), y / scale.toDouble()) + }, + ) { + Text(label) + } +} + +/** A `DropdownMenu` with enough items to be taller than the room at an edge. */ +@Composable +private fun EdgeMenu( + label: String, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier) { + Button(onClick = { expanded = !expanded }) { Text(label) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + repeat(MENU_ITEMS) { index -> + DropdownMenuItem( + text = { Text("Menu entry ${index + 1}") }, + onClick = { expanded = false }, + ) + } + } + } +} + +/** + * A popup deliberately larger than the owner window, anchored at its centre — + * the "tray anchor" shape. Without native popup layers it would be clipped to + * the window; with them it escapes, and with #569 it still stops at the + * display's work area rather than at some window-rooted phantom edge. + */ +@Composable +private fun EscapingPopupToggle(modifier: Modifier = Modifier) { + var shown by remember { mutableStateOf(false) } + Box(modifier) { + Button(onClick = { shown = !shown }) { + Text(if (shown) "hide oversized panel" else "show oversized panel") + } + if (shown) { + androidx.compose.ui.window.Popup( + alignment = Alignment.TopStart, + onDismissRequest = { shown = false }, + ) { + Card { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Oversized popup", style = MaterialTheme.typography.titleMedium) + Text("Measured ${OVERSIZE_W_DP}×$OVERSIZE_H_DP dp — larger than this window.") + AssistChip(onClick = { shown = false }, label = { Text("dismiss") }) + Spacer(Modifier.width(OVERSIZE_W_DP.dp).height(OVERSIZE_H_DP.dp)) + } + } + } + } + } +} + +private fun TaoWindow.outerRect(): IntRect? { + val rect = outerBoundsPx() ?: return null + if (rect.size < RECT_FIELDS) return null + val left = rect[0].toInt() + val top = rect[1].toInt() + return IntRect(left, top, left + rect[2].toInt(), top + rect[3].toInt()) +} + +private fun IntRect.describe(): String = "$left, $top $width×$height" + +private const val POLL_MILLIS = 200L +private const val ANCHOR_BOX_DP = 320 +private const val MENU_ITEMS = 14 +private const val OVERSIZE_W_DP = 520 +private const val OVERSIZE_H_DP = 420 +private const val RECT_FIELDS = 4 diff --git a/nucleus-application/build.gradle.kts b/nucleus-application/build.gradle.kts index 4b44a7492..e6e29f174 100644 --- a/nucleus-application/build.gradle.kts +++ b/nucleus-application/build.gradle.kts @@ -75,6 +75,28 @@ tasks.register("spellcheckConsumer") { mainClass.set("dev.nucleusframework.application.spellcheck.SpellcheckConsumerMainKt") } +/** + * Writes the test runtime classpath for `scripts/context-menu-wayland-e2e.py`, + * which launches `ContextMenuE2EMainKt` itself under a nested compositor (a + * JavaExec would not see the driver's WAYLAND_DISPLAY through the daemon). + */ +tasks.register("contextMenuE2EClasspath") { + group = "verification" + description = "Builds the test classes and writes their runtime classpath for the context menu E2E driver" + dependsOn(tasks.named("testClasses")) + val output = layout.buildDirectory.file("e2e/context-menu-classpath.txt") + val classpath = sourceSets["test"].runtimeClasspath + inputs.files(classpath) + outputs.file(output) + doLast { + output + .get() + .asFile + .apply { parentFile.mkdirs() } + .writeText(classpath.asPath) + } +} + tasks.register("systemThemeE2E") { group = "verification" description = diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt index 106c35a0a..4cf3760d1 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt @@ -52,8 +52,10 @@ public fun NucleusApplicationScope.DecoratedWindow( nativePopupLayers: Boolean = false, // Replace Compose-drawn context menus (ContextMenuArea, text // Cut/Copy/Paste, spellcheck items) with the OS-looking menu: `NSMenu` on - // macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent). - // Independent of [nativePopupLayers]. + // macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent). The + // flyout always opens in a native popup surface, whatever + // [nativePopupLayers] says — the rest of the window's popups follow that + // flag alone. nativeContextMenu: Boolean = false, // Hide this window from the OS taskbar/Dock while it stays visible and // focusable (macOS: NSApplication accessory policy, app-wide; Windows: diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/NativeContextMenuRepresentation.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/NativeContextMenuRepresentation.kt index 3e7bcb83a..59ce18e10 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/NativeContextMenuRepresentation.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/NativeContextMenuRepresentation.kt @@ -12,6 +12,7 @@ import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.menu.macos.NativePopupMenuItem import dev.nucleusframework.menu.macos.NsMenuItemImage import dev.nucleusframework.menu.macos.popUpNativeMenu +import dev.nucleusframework.window.tao.NativePopupLayers import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -21,6 +22,11 @@ import kotlinx.coroutines.withContext * Cinnamon, MATE, …), a Compose Breeze flyout on Qt Linux desktops (KDE * Plasma, LXQt, Deepin, …). * + * The Compose flyouts open in a native popup surface whatever the window's + * `nativePopupLayers` flag says ([NativePopupLayers]): an OS-looking menu has + * to be able to leave the window, like the menus it imitates, and the + * application's choice for its own popups must not decide that. + * * Calling [Representation] off a supported OS closes the menu immediately * so a stray install cannot leave Compose in `Open`. */ @@ -44,8 +50,10 @@ public object NativeContextMenuRepresentation : ContextMenuRepresentation { return } when (Platform.Current) { - Platform.Windows -> ContextMenuFlyout(status, entries, FluentMenuTheme, onDismiss) - Platform.Linux -> ContextMenuFlyout(status, entries, linuxContextMenuTheme(), onDismiss) + Platform.Windows -> + NativePopupLayers { ContextMenuFlyout(status, entries, FluentMenuTheme, onDismiss) } + Platform.Linux -> + NativePopupLayers { ContextMenuFlyout(status, entries, linuxContextMenuTheme(), onDismiss) } Platform.MacOS -> { val macEntries = entries.map { it.toMacPopupItem() } LaunchedEffect(status) { diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuE2EMain.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuE2EMain.kt new file mode 100644 index 000000000..f9fd243e5 --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuE2EMain.kt @@ -0,0 +1,159 @@ +@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) + +package dev.nucleusframework.application.contextmenu + +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem +import androidx.compose.foundation.ContextMenuState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.nucleusApplication +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger + +/** + * The loggers whose trace [main] forwards, held for the process's lifetime: + * `java.util.logging` keeps only a weak reference to a logger, so a collected + * one silently loses the configuration installed on it. + */ +private var tracedLoggers: List = emptyList() + +/** + * Process-level fixture for the compositor-driven context menu E2E + * (`scripts/context-menu-wayland-e2e.py`): one window painted a flat green, + * whose whole content is a [ContextMenuArea] using the OS-looking menu + * (`nativeContextMenu = true`, popups otherwise in-scene). Everything the + * driver needs to correlate with its screenshots goes to stdout, timestamped + * in milliseconds since start: pointer presses and releases as the scene sees + * them, every context menu status change, window focus flips, item clicks. + * + * Environment: `NUCLEUS_E2E_WINDOW_W` / `NUCLEUS_E2E_WINDOW_H` (dp, default + * 900×600). + */ +fun main(args: Array) { + val startNanos = System.nanoTime() + + fun log(message: String) { + val ms = (System.nanoTime() - startNanos) / 1_000_000 + println("[e2e $ms] $message") + System.out.flush() + } + // The popup layer's FINE trace, on the same clock as the lines above. + tracedLoggers = + listOf("dev.nucleusframework.window.tao.popup", "dev.nucleusframework.window.tao.scene").map { name -> + Logger.getLogger(name).apply { + level = Level.FINE + useParentHandlers = false + addHandler( + object : Handler() { + override fun publish(record: LogRecord) = log("LOG ${record.message}") + + override fun flush() = Unit + + override fun close() = Unit + }.apply { level = Level.ALL }, + ) + } + } + val width = System.getenv("NUCLEUS_E2E_WINDOW_W")?.toIntOrNull() ?: 900 + val height = System.getenv("NUCLEUS_E2E_WINDOW_H")?.toIntOrNull() ?: 600 + nucleusApplication(args, enableSingleInstance = false) { + DecoratedWindow( + onCloseRequest = ::exitApplication, + state = rememberWindowState(size = DpSize(width.dp, height.dp)), + title = "context-menu-e2e", + // NUCLEUS_E2E_NATIVE_CONTEXT_MENU=0 is the control: Compose's own + // in-scene menu, so a symptom can be attributed to the native + // surface or to Compose itself. + nativeContextMenu = System.getenv("NUCLEUS_E2E_NATIVE_CONTEXT_MENU") != "0", + ) { + val state = remember { ContextMenuState() } + val windowInfo = LocalWindowInfo.current + LaunchedEffect(Unit) { log("window content composed") } + LaunchedEffect(state) { + snapshotFlow { state.status }.collect { status -> + when (status) { + is ContextMenuState.Status.Open -> log("menu OPEN at ${status.rect.center}") + else -> log("menu CLOSED") + } + } + } + LaunchedEffect(windowInfo) { + snapshotFlow { windowInfo.isWindowFocused }.collect { log("window focused=$it") } + } + Box( + Modifier + .fillMaxSize() + .background(Color(0xFF00FF00)) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type == PointerEventType.Press || event.type == PointerEventType.Release) { + val change = event.changes.first() + log( + "pointer ${event.type} at ${change.position} " + + "secondary=${event.buttons.isSecondaryPressed} " + + "primary=${event.buttons.isPrimaryPressed}", + ) + } + } + } + }, + ) { + ContextMenuArea( + items = { + listOf( + ContextMenuItem("Alpha") { log("item Alpha") }, + ContextMenuItem("Bravo") { log("item Bravo") }, + ContextMenuItem("Charlie") { log("item Charlie") }, + ContextMenuItem("Delta") { log("item Delta") }, + ) + }, + state = state, + ) { + Box(Modifier.fillMaxSize()) + } + // Text context menu path (NativeTextContextMenu): a field in the + // top-left corner, 20..420 × 20..60 dp. + var text by remember { mutableStateOf(TextFieldValue("right click in this field")) } + BasicTextField( + value = text, + onValueChange = { text = it }, + modifier = + Modifier + .offset(20.dp, 20.dp) + .size(400.dp, 40.dp) + .background(Color.White) + .padding(8.dp), + ) + } + } + } +} diff --git a/scripts/context-menu-wayland-e2e.py b/scripts/context-menu-wayland-e2e.py new file mode 100755 index 000000000..c0f76b8f5 --- /dev/null +++ b/scripts/context-menu-wayland-e2e.py @@ -0,0 +1,476 @@ +#!/usr/bin/python3 +"""Compositor-driven E2E for the Linux context menu flyout on native Wayland. + +Boots a nested `gnome-shell --headless` (Mutter, the compositor the bug +reports come from), launches `ContextMenuE2EMainKt` on it, drives a real +pointer through `org.gnome.Mutter.RemoteDesktop`, and reads the result back +from `org.gnome.Shell.Screenshot` captures plus the fixture's own stdout log. + +Scenarios (each prints PASS/FAIL, exit code is the number of failures): + latency first frame of the menu within LATENCY_BUDGET_MS of the press + once the menu shows once per right click (no show/hide/show flicker) + bottom a menu opened near the bottom of a window sitting at the bottom of + the screen is fully visible (flipped or slid on screen) + repeat open / dismiss / open / dismiss / open all show a menu + reopen three right clicks in a row each move the menu + +Prerequisites: `./gradlew :nucleus-application:contextMenuE2EClasspath`, +GNOME Shell with --headless (Ubuntu 26.04), python3-gi, Pillow. +""" +import json +import os +import signal +import subprocess +import sys +import tempfile +import threading +import time + +import gi + +gi.require_version("Gio", "2.0") +gi.require_version("GLib", "2.0") +from gi.repository import Gio, GLib # noqa: E402 +from PIL import Image # noqa: E402 + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CLASSPATH_FILE = os.path.join(REPO, "nucleus-application/build/e2e/context-menu-classpath.txt") +MAIN_CLASS = "dev.nucleusframework.application.contextmenu.ContextMenuE2EMainKt" +JAVA = os.environ.get("NUCLEUS_E2E_JAVA", "/usr/lib/jvm/java-17-openjdk-amd64/bin/java") +WAYLAND_NAME = "nucleus-cm-e2e" +MONITOR_W, MONITOR_H = 1600, 1000 +WINDOW_W, WINDOW_H = 900, 600 +TITLE = "context-menu-e2e" +EDGE_INSET_PX = 10 # rounded corners and anti-aliased frame edges are not menu pixels +LATENCY_BUDGET_MS = 250 +LATENCY_SAMPLES = 4 +BTN_LEFT, BTN_RIGHT = 0x110, 0x111 +WORK = tempfile.mkdtemp(prefix="nucleus-cm-e2e-") + + +def log(msg): + print(f"[driver {time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +# ── nested shell ───────────────────────────────────────────────────────────── + +def start_shell(): + bus_file = os.path.join(WORK, "bus") + shell_log = open(os.path.join(WORK, "shell.log"), "w") + cmd = [ + "dbus-run-session", "--", "sh", "-c", + f"echo $DBUS_SESSION_BUS_ADDRESS > {bus_file}; exec gnome-shell --headless " + f"--virtual-monitor {MONITOR_W}x{MONITOR_H} --wayland-display={WAYLAND_NAME} --unsafe-mode", + ] + env = dict(os.environ) + env.pop("WAYLAND_DISPLAY", None) + env.pop("DISPLAY", None) + socket = os.path.join(os.environ["XDG_RUNTIME_DIR"], WAYLAND_NAME) + # A previous run killed mid-way leaves the socket and its lock behind, and + # Mutter then refuses to create its own. + for stale in (socket, socket + ".lock"): + if os.path.exists(stale): + os.remove(stale) + # Own process group: dbus-run-session does not forward SIGTERM to the shell. + proc = subprocess.Popen(cmd, stdout=shell_log, stderr=subprocess.STDOUT, env=env, start_new_session=True) + deadline = time.time() + 40 + while time.time() < deadline: + if os.path.exists(socket) and os.path.exists(bus_file) and os.path.getsize(bus_file) > 0: + break + if proc.poll() is not None: + raise SystemExit(f"gnome-shell exited early, see {shell_log.name}") + time.sleep(0.2) + else: + raise SystemExit("gnome-shell headless did not come up") + address = open(bus_file).read().strip() + # The Shell registers its D-Bus names a little after the socket appears. + bus = None + while time.time() < deadline: + try: + bus = Gio.DBusConnection.new_for_address_sync( + address, + Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, + None, None, + ) + bus.call_sync("org.gnome.Shell", "/org/gnome/Shell", "org.gnome.Shell", "Eval", + GLib.Variant("(s)", ("1",)), None, Gio.DBusCallFlags.NONE, 5000, None) + break + except GLib.Error: + time.sleep(0.5) + else: + raise SystemExit("org.gnome.Shell never answered") + log(f"nested shell up: WAYLAND_DISPLAY={WAYLAND_NAME} bus={address}") + return proc, bus, address + + +class Shell: + def __init__(self, bus): + self.bus = bus + + def call(self, dest, path, iface, method, params=None, timeout=10000): + return self.bus.call_sync(dest, path, iface, method, params, None, Gio.DBusCallFlags.NONE, timeout, None) + + def eval(self, js): + ok, result = self.call("org.gnome.Shell", "/org/gnome/Shell", "org.gnome.Shell", "Eval", + GLib.Variant("(s)", (js,))).unpack() + if not ok: + raise RuntimeError(f"Eval failed: {result}") + # Eval JSON-encodes its result; a JS expression that already returned a + # JSON string therefore comes back double-encoded. + value = json.loads(result) if result else None + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + pass + return value + + def windows(self): + return self.eval( + "JSON.stringify(global.get_window_actors().map(a => { const w = a.meta_window; " + "const r = w.get_frame_rect(); const b = w.get_buffer_rect(); " + "return {title: w.get_title(), type: w.get_window_type(), " + "x: r.x, y: r.y, w: r.width, h: r.height, bx: b.x, by: b.y, bw: b.width, bh: b.height}; }))" + ) + + def find_window(self, title, timeout=60): + deadline = time.time() + timeout + while time.time() < deadline: + for w in self.windows() or []: + if w["title"] == title and w["w"] > 1: + return w + time.sleep(0.25) + raise SystemExit(f"window {title!r} never appeared; windows={self.windows()}") + + def move_window(self, title, x, y): + self.eval( + "(() => { const w = global.get_window_actors().map(a => a.meta_window)" + f".find(w => w.get_title() === {json.dumps(title)}); w.move_frame(true, {x}, {y}); return 'ok'; }})()" + ) + + def screenshot(self, path): + ok, used = self.call("org.gnome.Shell.Screenshot", "/org/gnome/Shell/Screenshot", + "org.gnome.Shell.Screenshot", "Screenshot", + GLib.Variant("(bbs)", (False, False, path))).unpack() + if not ok: + raise RuntimeError("screenshot failed") + return Image.open(used).convert("RGB") + + +class Pointer: + """org.gnome.Mutter.RemoteDesktop pointer: the only injection Mutter accepts on Wayland.""" + + def __init__(self, shell): + self.shell = shell + rd = "org.gnome.Mutter.RemoteDesktop" + sc = "org.gnome.Mutter.ScreenCast" + (self.session,) = shell.call(rd, "/org/gnome/Mutter/RemoteDesktop", rd, "CreateSession").unpack() + (session_id,) = shell.call(rd, self.session, "org.freedesktop.DBus.Properties", "Get", + GLib.Variant("(ss)", (rd + ".Session", "SessionId"))).unpack() + (sc_session,) = shell.call( + sc, "/org/gnome/Mutter/ScreenCast", sc, "CreateSession", + GLib.Variant("(a{sv})", ({"remote-desktop-session-id": GLib.Variant("s", session_id)},)), + ).unpack() + shell.call(rd, self.session, rd + ".Session", "Start") + (self.stream,) = shell.call( + sc, sc_session, sc + ".Session", "RecordMonitor", + GLib.Variant("(sa{sv})", ("Meta-0", {"cursor-mode": GLib.Variant("u", 1)})), + ).unpack() + self.rd = rd + log(f"remote desktop session {self.session} stream {self.stream}") + + def move(self, x, y): + self.shell.call(self.rd, self.session, self.rd + ".Session", "NotifyPointerMotionAbsolute", + GLib.Variant("(sdd)", (self.stream, float(x), float(y)))) + + def button(self, code, pressed): + self.shell.call(self.rd, self.session, self.rd + ".Session", "NotifyPointerButton", + GLib.Variant("(ib)", (code, pressed))) + + def click(self, x, y, code=BTN_LEFT, hold_ms=60): + self.move(x, y) + time.sleep(0.05) + self.button(code, True) + time.sleep(hold_ms / 1000) + self.button(code, False) + + +# ── the app under test ─────────────────────────────────────────────────────── + +class App: + def __init__(self, wayland, bus_address): + classpath = open(CLASSPATH_FILE).read().strip() + env = dict(os.environ) + env.update({ + "WAYLAND_DISPLAY": wayland, + "GDK_BACKEND": "wayland", + "DBUS_SESSION_BUS_ADDRESS": bus_address, + "NUCLEUS_E2E_WINDOW_W": str(WINDOW_W), + "NUCLEUS_E2E_WINDOW_H": str(WINDOW_H), + }) + env.pop("DISPLAY", None) + if os.environ.get("E2E_WAYLAND_DEBUG"): + env["WAYLAND_DEBUG"] = "1" + self.lines = [] + self.log_path = os.path.join(WORK, "app.log") + self.proc = subprocess.Popen([JAVA, "-cp", classpath, MAIN_CLASS], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.start = time.time() + threading.Thread(target=self._pump, daemon=True).start() + + def _pump(self): + with open(self.log_path, "w") as out: + for line in self.proc.stdout: + self.lines.append((time.time(), line.rstrip())) + out.write(line) + out.flush() + + def since(self, t): + return [l for (ts, l) in self.lines if ts >= t and l.startswith("[e2e")] + + def stop(self): + self.proc.terminate() + try: + self.proc.wait(10) + except subprocess.TimeoutExpired: + self.proc.kill() + + +# ── pixel analysis ─────────────────────────────────────────────────────────── + +TEXT_FIELD_DP = (20, 20, 420, 60) # the fixture's white text field, window-relative + + +def menu_bbox(img, region, win=None): + """Bounding box of non-green pixels inside region=(x0,y0,x1,y1), or None. + + The fixture's text field is white too; its rectangle is skipped. + """ + x0, y0, x1, y1 = region + skip = None + if win is not None: + fx0, fy0, fx1, fy1 = TEXT_FIELD_DP + skip = (win["x"] + fx0 - 2, win["y"] + fy0 - 2, win["x"] + fx1 + 2, win["y"] + fy1 + 2) + crop = img.crop((x0, y0, x1, y1)) + px = crop.load() + xs, ys = [], [] + w, h = crop.size + for y in range(0, h, 2): + for x in range(0, w, 2): + if skip and skip[0] <= x0 + x < skip[2] and skip[1] <= y0 + y < skip[3]: + continue + r, g, b = px[x, y] + if abs(r) > 70 or abs(255 - g) > 70 or abs(b) > 70: + xs.append(x) + ys.append(y) + if len(xs) < 40: # a few stray pixels are not a menu + return None + return (x0 + min(xs), y0 + min(ys), x0 + max(xs) + 1, y0 + max(ys) + 1) + + +def menu_bbox_win(img, region, win): + return menu_bbox(img, region, win) + + +def content_region(win, to_screen_bottom=False): + x0, y0 = win["x"] + EDGE_INSET_PX, win["y"] + EDGE_INSET_PX + x1 = win["x"] + win["w"] - EDGE_INSET_PX + y1 = MONITOR_H if to_screen_bottom else win["y"] + win["h"] - EDGE_INSET_PX + return (x0, y0, x1, y1) + + +def observe(shell, region, win, seconds, period=0.04): + """Samples screenshots for `seconds`; returns [(t_rel_ms, bbox or None)].""" + samples = [] + start = time.time() + n = 0 + while time.time() - start < seconds: + path = os.path.join(WORK, f"shot-{int(start)}-{n}.png") + n += 1 + img = shell.screenshot(path) + samples.append((int((time.time() - start) * 1000), menu_bbox(img, region, win), path)) + time.sleep(period) + return samples + + +def app_ms(lines, needle): + """Timestamp (ms, app clock) of the first fixture line containing needle.""" + for line in lines: + if needle in line: + return int(line.split("]")[0].split(" ")[1]) + return None + + +# ── scenarios ──────────────────────────────────────────────────────────────── + +class Report: + def __init__(self): + self.failures = 0 + + def check(self, name, ok, detail): + print(f"{'PASS' if ok else 'FAIL'} {name}: {detail}", flush=True) + if not ok: + self.failures += 1 + + +def run(): + shell_proc, bus, address = start_shell() + shell = Shell(bus) + app = App(WAYLAND_NAME, address) + report = Report() + try: + win = shell.find_window(TITLE) + log(f"window: {win}") + pointer = Pointer(shell) + # Wake the app's input path and make sure the window is focused/active. + cx, cy = win["x"] + win["w"] // 2, win["y"] + win["h"] // 2 + pointer.click(cx, cy) + time.sleep(0.5) + t0 = time.time() + pointer.click(cx, cy) + time.sleep(0.5) + report.check("input reaches the window", any("pointer Press" in l for l in app.since(t0)), + f"log={app.since(t0)}") + + # ── latency + once, window in the middle of the screen ────────────── + shell.move_window(TITLE, (MONITOR_W - win["w"]) // 2, (MONITOR_H - win["h"]) // 2) + time.sleep(0.6) + win = shell.find_window(TITLE) + region = content_region(win) + cx, cy = win["x"] + win["w"] // 2, win["y"] + win["h"] // 2 + # Latency over several menus, from the app's own trace: screenshots are + # heavy enough to starve the compositor's frame callbacks, so measuring + # the first menu while sampling pixels measures the driver, not the app. + latencies = [] + for _ in range(LATENCY_SAMPLES): + t = time.time() + pointer.click(cx, cy, BTN_RIGHT) + time.sleep(0.6) + trace = app.since(t) + press = app_ms(trace, "pointer Press") + present = app_ms(trace, "first present") or app_ms(trace, "menu OPEN") + latencies.append((present - press) if (press is not None and present is not None) else None) + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.5) + report.check("latency", all(v is not None and v <= LATENCY_BUDGET_MS for v in latencies), + f"press→first present per menu: {latencies} ms (budget {LATENCY_BUDGET_MS})") + stalls = [l for l in app.since(t0) if "frame stalled" in l] + report.check("no frame stall while opening a menu", not stalls, f"stalls={stalls[:6]}") + + pointer.move(cx, cy) + time.sleep(0.1) + t_press = time.time() + pointer.button(BTN_RIGHT, True) + time.sleep(0.05) + pointer.button(BTN_RIGHT, False) + samples = observe(shell, region, win, 1.6) + visible = [(t, b) for (t, b, _) in samples] + first = next((t for (t, b) in visible if b), None) + report.check("visible on screen after the press", first is not None, + f"first screenshot with the menu at {first} ms; trace:\n " + "\n ".join(app.since(t_press))) + # show / hide / show within the window is the double display. + pattern = [] + for (_, b) in visible: + v = bool(b) + if not pattern or pattern[-1] != v: + pattern.append(v) + report.check("once", pattern.count(True) <= 1 and (not pattern or pattern[-1] is True), + f"visibility pattern={pattern} log={app.since(t_press)}") + ref = next((b for (_, b) in reversed(visible) if b), None) + ref_h = (ref[3] - ref[1]) if ref else None + log(f"reference menu bbox={ref} height={ref_h}") + # dismiss with a left click far from the menu + t_dismiss = time.time() + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.5) + gone = menu_bbox_win(shell.screenshot(os.path.join(WORK, "after-dismiss.png")), region, win) is None + report.check("dismiss on outside click", gone, f"log={app.since(t_dismiss)}") + + # ── bottom: window flush with the screen bottom, click near its bottom ─ + shell.move_window(TITLE, (MONITOR_W - win["w"]) // 2, MONITOR_H - win["h"]) + time.sleep(0.6) + win = shell.find_window(TITLE) + log(f"window at bottom: {win}") + region = content_region(win, to_screen_bottom=True) + bx, by = win["x"] + win["w"] // 2, min(win["y"] + win["h"] - 30, MONITOR_H - 30) + t_press = time.time() + pointer.click(bx, by, BTN_RIGHT) + time.sleep(0.8) + shot = shell.screenshot(os.path.join(WORK, "bottom.png")) + bbox = menu_bbox(shot, region, win) + ok = bbox is not None and bbox[3] < MONITOR_H - 1 and (ref_h is None or abs((bbox[3] - bbox[1]) - ref_h) <= 4) + report.check("bottom", ok, + f"menu bbox={bbox} reference height={ref_h} screen height={MONITOR_H} " + f"click=({bx},{by}) log={app.since(t_press)}") + pointer.click(win["x"] + 40, win["y"] + 80) + time.sleep(0.5) + + # ── repeat: open / dismiss ×3 in the middle ───────────────────────── + shell.move_window(TITLE, (MONITOR_W - win["w"]) // 2, (MONITOR_H - win["h"]) // 2) + time.sleep(0.6) + win = shell.find_window(TITLE) + region = content_region(win) + cx, cy = win["x"] + win["w"] // 2, win["y"] + win["h"] // 2 + for i in range(3): + t_press = time.time() + pointer.click(cx, cy, BTN_RIGHT) + time.sleep(0.7) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, f"repeat-{i}.png")), region, win) + report.check(f"repeat #{i + 1} shows", bbox is not None, f"bbox={bbox} log={app.since(t_press)}") + t_dismiss = time.time() + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.6) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, f"repeat-{i}-closed.png")), region, win) + report.check(f"repeat #{i + 1} dismisses", bbox is None, f"bbox={bbox} log={app.since(t_dismiss)}") + + # ── reopen: three right clicks in a row, no dismiss in between ───── + points = [(cx - 200, cy - 100), (cx + 100, cy), (cx - 50, cy + 120)] + for i, (px, py) in enumerate(points): + t_press = time.time() + pointer.click(px, py, BTN_RIGHT) + time.sleep(0.7) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, f"reopen-{i}.png")), region, win) + near = bbox is not None and abs(bbox[0] - px) < 40 and abs(bbox[1] - py) < 40 + report.check(f"reopen #{i + 1} shows at the click", near, + f"click=({px},{py}) bbox={bbox} log={app.since(t_press)}") + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.4) + + # ── textfield: the text context menu path, as in the demo ─────────── + tx, ty = win["x"] + 200, win["y"] + 40 + t_press = time.time() + pointer.click(tx, ty, BTN_RIGHT) + time.sleep(0.8) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, "textfield.png")), region, win) + report.check("textfield shows", bbox is not None, f"bbox={bbox} log={app.since(t_press)}") + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.6) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, "textfield-closed.png")), region, win) + report.check("textfield dismisses", bbox is None, f"bbox={bbox}") + + # ── hold: a right click held longer than the menu takes to appear ─── + for i in range(2): + t_press = time.time() + pointer.click(cx, cy, BTN_RIGHT, hold_ms=350) + time.sleep(0.6) + trace = app.since(t_press) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, f"hold-{i}.png")), region, win) + released = any("pointer Release" in l for l in trace) + report.check(f"hold #{i + 1} shows and the window sees the release", bbox is not None and released, + f"bbox={bbox} log={trace}") + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.6) + finally: + log(f"artifacts in {WORK}") + app.stop() + os.killpg(shell_proc.pid, signal.SIGTERM) + try: + shell_proc.wait(10) + except subprocess.TimeoutExpired: + os.killpg(shell_proc.pid, signal.SIGKILL) + print(f"failures={report.failures}", flush=True) + return report.failures + + +if __name__ == "__main__": + sys.exit(min(run(), 100))