From 39a70aec72b61253f2fe0bd527f2fc5da23bc37a Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Sat, 5 Sep 2026 20:31:36 +0300 Subject: [PATCH 1/7] fix(tao): place native popup layers against the screen, not the window (#569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nativePopupLayers` made every Compose `Popup` a real OS window, but the *decision* of where to put it stayed window-rooted, in two stacked ways. The layers each built a work-area-sized `WindowInfo` so a popup could lay out and flip against the display — and then `setContent` replayed the owner window's composition locals over it, so `Popup.skiko.kt` read the window's `containerSize` and clipped every popup back inside the window. The intended design had never taken effect. The layers now re-provide `LocalWindowInfo` inside the replayed locals. With that in force the box is screen-sized but still rooted at the window's content origin, so a `DropdownMenu` in a window near the bottom of the display did not flip up — Compose saw a whole work area of room below the anchor — and walked off the screen. Each layer now clamps its native frame into the work area of the display it lands on, at the single point where it pushes that frame: `popupScreenClampOffset`, fed the owner's content origin on screen plus every display's work area. Only the native frame moves; `boundsInWindow` stays what Compose believes, which is what hit-testing and the surface content are expressed in. Re-clamped on every push, so an open popup survives an owner drag, across monitors included. Dialogs go through the same layers but must not follow the display: `Dialog.skiko.kt` places at `containerSize.center`, so a window-owned dialog centred on the screen would sit visibly off-centre and drift as the window moved. Layers report the window size for dialogs and the work area for popups, discriminated on `scrimColor` — only `Dialog.skiko.kt` writes it, from `DialogAppearanceController.properties` during `DialogLayout`'s composition, before `layer.Content { }` reads the container. macOS needs the NSView's own origin on screen (a native title bar sits between it and the window frame), hence `nativeGetContentRect`. Wayland reports no geometry and is left unclamped: a popup there is a `wl_subsurface` placed relative to the parent, with no global position. Also exposes `nativePopupLayers` on `JewelDecoratedWindow`, which had no such parameter at all — Jewel apps could not opt in. Jewel needs nothing further: `DefaultPopupRenderer` delegates to `androidx.compose.ui.window.Popup`, so its combo boxes, menus and tooltips flow through the fixed layers. Tests: 18 unit cases on the clamp geometry, and 13 headful cases driving real windows parked at real work-area edges — including one that reads the popup HWND's rect back through Win32 and asserts it matches the reported frame to the pixel, and two that pin the dialog contract. A new "Popups" tab in nucleus-demo parks the window at any corner and opens menus anchored at each window edge. --- .../window/jewel/JewelDecoratedWindow.kt | 12 + .../tao/ffi/NativeTaoMacOsDecoBridge.kt | 15 + .../window/tao/popup/PopupScreenClamp.kt | 70 ++ .../window/tao/popup/PopupScreenGeometry.kt | 47 ++ .../window/tao/popup/TaoPopupDiagnostics.kt | 73 ++ .../window/tao/popup/TaoPopupHost.kt | 15 + .../window/tao/popup/TaoPopupHostLinux.kt | 11 + .../window/tao/popup/TaoPopupHostWindows.kt | 8 + .../window/tao/popup/TaoPopupSceneLayer.kt | 100 ++- .../tao/popup/TaoPopupSceneLayerLinux.kt | 95 ++- .../tao/popup/TaoPopupSceneLayerWindows.kt | 99 ++- .../window/tao/scene/TaoComposeSceneHost.kt | 26 + .../tao/scene/TaoComposeSceneHostLinux.kt | 14 + .../tao/scene/TaoComposeSceneHostWindows.kt | 30 + .../src/main/native/macos/decoration.m | 29 + .../tao/TaoSceneTestBatteryDriftTest.kt | 2 + .../NativePopupPlacementHeadfulCases.kt | 664 ++++++++++++++++++ .../tao/headful/TaoHeadfulTestSuiteMain.kt | 1 + .../window/tao/popup/PopupScreenClampTest.kt | 243 +++++++ .../src/main/kotlin/jewelsample/Main.kt | 7 + .../src/main/kotlin/com/example/demo/Main.kt | 4 +- .../com/example/demo/PopupPlacementScreen.kt | 272 +++++++ 22 files changed, 1816 insertions(+), 21 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClamp.kt create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenGeometry.kt create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClampTest.kt create mode 100644 examples/nucleus-demo/src/main/kotlin/com/example/demo/PopupPlacementScreen.kt 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..7add30d0a 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 @@ -66,6 +66,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 +105,7 @@ public fun NucleusApplicationScope.JewelDecoratedWindow( forceX11 = forceX11, undecorated = undecorated, popupFor = popupFor, + nativePopupLayers = nativePopupLayers, nativeContextMenu = nativeContextMenu, hiddenFromDock = hiddenFromDock, minimumSize = minimumSize, 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/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/TaoPopupDiagnostics.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt new file mode 100644 index 000000000..96dceff1d --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt @@ -0,0 +1,73 @@ +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, + /** Where the popup was placed, in global screen physical px. */ + val frameOnScreenPx: 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++ + } + + fun reset() { + last.set(null) + frameCount = 0 + } +} 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..c6a64acf5 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 @@ -51,6 +51,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 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..a4d998b92 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 @@ -59,6 +59,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 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..774cadd86 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 @@ -42,6 +42,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 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..9372aaf60 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 @@ -64,6 +67,13 @@ import org.jetbrains.skia.DirectContext * what makes `MaterialTheme.colorScheme` etc. flow into the popup * content automatically. * + * 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. + * * Phase 3 deliberately omits: * - `setOutsidePointerEventListener` — outside-click dismissal lands * in Phase 4 (NSEvent local monitor on the parent window). @@ -86,7 +96,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 +117,38 @@ 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.parentWindowSize.let { + IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) + } + /** * Panel created at parent-window-size offscreen so the inner scene * has real layout constraints, while the user doesn't see a 1×1 @@ -197,7 +239,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 = @@ -347,13 +390,44 @@ internal class TaoPopupSceneLayer( // is zero; for `NativeView`'s overlay scene it is the overlay's // own position within the host NSWindow. val offset = host.coordinateOffset + val frameInParent = + IntRect( + left = value.left + offset.x, + top = value.top + offset.y, + right = value.right + offset.x, + bottom = value.bottom + offset.y, + ) + // Screen clamp (#569): Compose decided this position inside a + // work-area-sized virtual screen rooted at the window's content + // origin, so it can point off the real display. Only the panel's + // frame moves — `_bounds` stays what Compose believes, which is + // what [calculateLocalPosition] and the panel-local pointer + // coordinates are expressed in (the scene draws at the panel's + // own top-left, so the content follows the panel for free). + val clamp = popupScreenClampOffset(frameInParent, host.popupScreenGeometry) PopupNativeBridge.nativeSetFrameInWindow( panel = panelHandle, - xPx = value.left + offset.x, - yPx = value.top + offset.y, + xPx = frameInParent.left + clamp.x, + yPx = frameInParent.top + clamp.y, widthPx = value.width.coerceAtLeast(1), heightPx = value.height.coerceAtLeast(1), ) + host.popupScreenGeometry?.let { geometry -> + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = value, + frameOnScreenPx = + IntRect( + left = geometry.parentContentOriginPx.x + frameInParent.left + clamp.x, + top = geometry.parentContentOriginPx.y + frameInParent.top + clamp.y, + right = geometry.parentContentOriginPx.x + frameInParent.right + clamp.x, + bottom = geometry.parentContentOriginPx.y + frameInParent.bottom + clamp.y, + ), + clampOffsetPx = clamp, + panelHandle = panelHandle, + ), + ) + } // 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. @@ -375,9 +449,9 @@ internal class TaoPopupSceneLayer( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value // TODO Phase 4: third surface + scrimColorState.value = value } override var focusable: Boolean @@ -442,7 +516,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() } } 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..568a87997 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 @@ -93,7 +94,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() @@ -142,6 +143,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.parentWindowSize.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 +203,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 = @@ -320,9 +354,9 @@ internal class TaoPopupSceneLayerLinux( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value + scrimColorState.value = value } override var focusable: Boolean @@ -382,7 +416,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,13 +474,48 @@ 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 val origin = host.parentScreenOriginPx val offset = host.coordinateOffset - val xPx = _bounds.left + offset.x + origin.x - val yPx = _bounds.top + offset.y + origin.y + val frameInParent = + IntRect( + left = _bounds.left + offset.x, + top = _bounds.top + offset.y, + right = _bounds.right + offset.x, + bottom = _bounds.bottom + offset.y, + ) + val geometry = host.popupScreenGeometry + val clamp = popupScreenClampOffset(frameInParent, geometry) + val xPx = frameInParent.left + clamp.x + origin.x + val yPx = frameInParent.top + clamp.y + origin.y + geometry?.let { + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = _bounds, + frameOnScreenPx = + IntRect( + left = it.parentContentOriginPx.x + frameInParent.left + clamp.x, + top = it.parentContentOriginPx.y + frameInParent.top + clamp.y, + right = it.parentContentOriginPx.x + frameInParent.right + clamp.x, + bottom = it.parentContentOriginPx.y + frameInParent.bottom + clamp.y, + ), + 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 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..19c35d5dd 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 @@ -68,7 +69,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,6 +94,39 @@ 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.parentWindowSize.let { + IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) + } + private var drawBounds: IntRect = IntRect(0, 0, 1, 1) private var widthPx: Int = 1 private var heightPx: Int = 1 @@ -162,7 +196,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 = @@ -316,9 +351,9 @@ internal class TaoPopupSceneLayerWindows( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value + scrimColorState.value = value } override var focusable: Boolean @@ -354,6 +389,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() } @@ -439,12 +485,53 @@ 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 val offset = host.coordinateOffset - val finalX = drawBounds.left + offset.x - val finalY = drawBounds.top + offset.y + val frameInParent = + IntRect( + left = drawBounds.left + offset.x, + top = drawBounds.top + offset.y, + right = drawBounds.right + offset.x, + bottom = drawBounds.bottom + offset.y, + ) + val geometry = host.popupScreenGeometry + val clamp = popupScreenClampOffset(frameInParent, geometry) + val finalX = frameInParent.left + clamp.x + val finalY = frameInParent.top + clamp.y + geometry?.let { + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = _bounds, + frameOnScreenPx = + IntRect( + left = it.parentContentOriginPx.x + finalX, + top = it.parentContentOriginPx.y + finalY, + right = it.parentContentOriginPx.x + finalX + frameInParent.width, + bottom = it.parentContentOriginPx.y + finalY + frameInParent.height, + ), + clampOffsetPx = clamp, + panelHandle = panelHandle, + ), + ) + } PopupNativeBridgeWindows.nativeSetFrameInWindow( panel = panelHandle, xPx = finalX, 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..10e90c6de 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,7 @@ 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.TaoPopupHost import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayer import dev.nucleusframework.window.tao.render.LocalTaoTextSelectionA11yPublisher @@ -885,6 +888,26 @@ 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, + ) + } + fun popupHost(): TaoPopupHost? { if (nsViewHandle == 0L) return null val outer = this @@ -900,6 +923,9 @@ 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 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..8d05d0cb7 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 @@ -31,6 +31,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 +54,7 @@ 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.TaoPopupHostLinux import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayerLinux import dev.nucleusframework.window.tao.releaseGlTextureImports @@ -2177,6 +2179,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 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..478ed764f 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,7 @@ 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.TaoPopupHostWindows import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayerWindows import dev.nucleusframework.window.tao.releaseWindowsTextureImports @@ -1569,6 +1572,30 @@ 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, + ) + } + fun popupHost(): TaoPopupHostWindows? { if (hwnd == 0L) return null val ctx = directContext ?: return null @@ -1589,6 +1616,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 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/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index b268a3667..efdf846bb 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 @@ -125,6 +125,8 @@ 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", 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/NativePopupPlacementHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt new file mode 100644 index 000000000..02c0141f7 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt @@ -0,0 +1,664 @@ +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.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(), + ) + + // ── 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.frameOnScreenPx.left > windowRight) { + "popup must be allowed outside the owner window: " + + "frame=${record.frameOnScreenPx} 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.frameOnScreenPx + // 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.frameOnScreenPx.width >= minWidthPx) { + "popup collapsed toward the owner window size: ${record.frameOnScreenPx}" + } + } + + // ── 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}" + } + val work = workArea() + check(actual.top >= work.top && actual.bottom <= work.bottom) { + "the OS placed the popup outside the work area: $actual 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() + val frame = record.frameOnScreenPx + 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 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) + + @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 + if (shown) { + Dialog(onDismissRequest = { }) { + Box(Modifier.size(DIALOG_W_DP.dp, DIALOG_H_DP.dp).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. */ + private fun TaoWindowTestScope.checkOnWorkArea(record: PopupFrameRecord) { + val frame = record.frameOnScreenPx + val areas = TaoMonitors.all(window).map { it.workAreaPx } + check(areas.any { frame.fitsIn(it) }) { + "popup landed outside every work area: frame=$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} 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_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..65a80f893 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,7 @@ public object TaoHeadfulTestSuiteMain { FramePacingHeadfulCases.all() + MacWindowChromeStateHeadfulCases.all() + PopupScaleHeadfulCases.all() + + NativePopupPlacementHeadfulCases.all() + ClipboardHeadfulCases.all() + AnimatedWindowSizeHeadfulCases.all() + AlwaysOnTopHeadfulCases.all() + 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/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..b696019b1 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 = true ) { 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 From 4c0c0145555e15288a7d227a15fec05f4b89aab1 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Sat, 5 Sep 2026 21:57:41 +0300 Subject: [PATCH 2/7] fix(tao): make native popup layer dialogs look like in-scene ones (#569) A Compose Dialog opened through nativePopupLayers had no scrim, clipped its shadow and its appearance animation at the layout edge, slid diagonally towards the display centre while scaling in, and stayed put when the window was resized. - Scrims: the owner window paints every layer's scrim after its content and each layer paints the scrims of the layers above it (PopupScrimRegistry, TaoSceneBundle.renderOverlay). A scrim change marks the owner scene visually dirty, so the Windows clean-frame present skip no longer eats the fade. - Draw margin: the native surface extends 32 dp past boundsInWindow so shadows and the 10 dp slide-in are not clipped. Compose 1.12 renders a scene as one RenderNode drawable with unbounded bounds, so the R-tree cull-rect measurement upstream uses reports the whole canvas; a constant margin replaces it. The screen clamp is decided on the content rect; the interactive region stays the content (Windows content rect, macOS region hit-test, Linux press filter). - Dialog scene size: a dialog's root Layout fills the layer scene's constraints and carries the appearance GraphicsLayer, so its scale pivots on the scene centre. Dialog layers now run their inner scene at the owner window size, popups keep the work area. - Resize: the dialog container size is read from the owner's snapshot-backed WindowInfo, so the dialog re-centres when the window is resized. DialogAppearanceHeadfulCases films both layer modes with java.awt.Robot and compares slide-in, scrim ramp and settle time; PopupFrameRecord gains the content frame next to the inflated native frame. --- decorated-window-tao/build.gradle.kts | 3 + .../window/tao/popup/PopupDrawInflate.kt | 43 ++ .../window/tao/popup/PopupScrimRegistry.kt | 113 +++++ .../window/tao/popup/TaoPopupDiagnostics.kt | 9 +- .../window/tao/popup/TaoPopupHost.kt | 17 + .../window/tao/popup/TaoPopupHostLinux.kt | 7 + .../window/tao/popup/TaoPopupHostWindows.kt | 7 + .../window/tao/popup/TaoPopupSceneLayer.kt | 219 ++++++---- .../tao/popup/TaoPopupSceneLayerLinux.kt | 95 ++++- .../tao/popup/TaoPopupSceneLayerWindows.kt | 79 +++- .../window/tao/scene/MetalSceneRenderer.kt | 10 + .../window/tao/scene/TaoComposeSceneHost.kt | 45 +- .../tao/scene/TaoComposeSceneHostLinux.kt | 42 +- .../tao/scene/TaoComposeSceneHostWindows.kt | 43 +- .../window/tao/scene/TaoSceneBundle.kt | 10 + .../tao/TaoSceneTestBatteryDriftTest.kt | 8 + .../headful/DialogAppearanceHeadfulCases.kt | 403 ++++++++++++++++++ .../NativePopupPlacementHeadfulCases.kt | 85 +++- .../tao/headful/TaoHeadfulTestSuiteMain.kt | 1 + .../window/tao/popup/PopupDrawInflateTest.kt | 45 ++ .../tao/popup/PopupScrimRegistryTest.kt | 148 +++++++ 21 files changed, 1287 insertions(+), 145 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflate.kt create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistry.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflateTest.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistryTest.kt diff --git a/decorated-window-tao/build.gradle.kts b/decorated-window-tao/build.gradle.kts index b2fe69864..a24f4c6ec 100644 --- a/decorated-window-tao/build.gradle.kts +++ b/decorated-window-tao/build.gradle.kts @@ -168,6 +168,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/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/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 index 96dceff1d..e336d81dd 100644 --- 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 @@ -16,8 +16,15 @@ import java.util.concurrent.atomic.AtomicReference internal class PopupFrameRecord( /** `boundsInWindow` as Compose computed it, unclamped. Window-rooted physical px. */ val boundsInWindowPx: IntRect, - /** Where the popup was placed, in global screen physical px. */ + /** + * 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, /** 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 c6a64acf5..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 @@ -96,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 a4d998b92..b81721746 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 @@ -84,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() /** 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 774cadd86..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 @@ -88,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 9372aaf60..082f007f2 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 @@ -39,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 @@ -74,13 +75,9 @@ import org.jetbrains.skia.DirectContext * 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. * - * 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. + * `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. */ @@ -145,10 +142,19 @@ internal class TaoPopupSceneLayer( */ private val dialogContainerSize: IntSize get() = - host.parentWindowSize.let { + 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 + /** * Panel created at parent-window-size offscreen so the inner scene * has real layout constraints, while the user doesn't see a 1×1 @@ -215,11 +221,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 @@ -269,10 +275,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 @@ -307,7 +344,7 @@ internal class TaoPopupSceneLayer( } innerScene.sendPointerEvent( eventType = eventType, - position = Offset(x, y), + position = scenePosition(x, y), type = PointerType.Mouse, button = pointerButton, ) @@ -320,9 +357,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), ) } @@ -362,7 +400,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 ────────────────────────────────────── @@ -385,63 +425,81 @@ 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 - val frameInParent = - IntRect( - left = value.left + offset.x, - top = value.top + offset.y, - right = value.right + offset.x, - bottom = value.bottom + offset.y, - ) - // Screen clamp (#569): Compose decided this position inside a - // work-area-sized virtual screen rooted at the window's content - // origin, so it can point off the real display. Only the panel's - // frame moves — `_bounds` stays what Compose believes, which is - // what [calculateLocalPosition] and the panel-local pointer - // coordinates are expressed in (the scene draws at the panel's - // own top-left, so the content follows the panel for free). - val clamp = popupScreenClampOffset(frameInParent, host.popupScreenGeometry) - PopupNativeBridge.nativeSetFrameInWindow( - panel = panelHandle, - xPx = frameInParent.left + clamp.x, - yPx = frameInParent.top + clamp.y, - widthPx = value.width.coerceAtLeast(1), - heightPx = value.height.coerceAtLeast(1), - ) - host.popupScreenGeometry?.let { geometry -> - TaoPopupDiagnostics.record( - PopupFrameRecord( - boundsInWindowPx = value, - frameOnScreenPx = - IntRect( - left = geometry.parentContentOriginPx.x + frameInParent.left + clamp.x, - top = geometry.parentContentOriginPx.y + frameInParent.top + clamp.y, - right = geometry.parentContentOriginPx.x + frameInParent.right + clamp.x, - bottom = geometry.parentContentOriginPx.y + frameInParent.bottom + clamp.y, - ), - clampOffsetPx = clamp, - panelHandle = panelHandle, - ), - ) - } - // 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) - } + 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 (_bounds == IntRect.Zero || disposed) return + drawBounds = popupDrawBounds(_bounds, _density.density) + val offset = host.coordinateOffset + val contentInParent = _bounds.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( + (_bounds.left - drawBounds.left).toFloat(), + (_bounds.top - drawBounds.top).toFloat(), + _bounds.width.toFloat(), + _bounds.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) { @@ -452,6 +510,10 @@ internal class TaoPopupSceneLayer( get() = scrimColorState.value set(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 @@ -474,6 +536,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 @@ -560,14 +623,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) ────── @@ -580,12 +639,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 568a87997..7ba02e887 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 @@ -22,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 @@ -43,6 +44,7 @@ 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 kotlin.math.roundToInt @@ -110,6 +112,14 @@ 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 + /** EGL attachment ready — flips on WINDOW_READY once the GPU side is up. */ private var attachment: Long = 0 private var directContext: DirectContext? = null @@ -171,7 +181,7 @@ internal class TaoPopupSceneLayerLinux( */ private val dialogContainerSize: IntSize get() = - host.parentWindowSize.let { + host.parentWindowInfo.containerSize.let { IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) } @@ -238,10 +248,41 @@ 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 @@ -252,6 +293,7 @@ internal class TaoPopupSceneLayerLinux( 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() @@ -357,6 +399,10 @@ internal class TaoPopupSceneLayerLinux( get() = scrimColorState.value set(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 @@ -374,6 +420,7 @@ internal class TaoPopupSceneLayerLinux( if (released) return released = true host.unregisterRenderer(rendererToken) + host.popupScrims.unregister(rendererToken) host.unregisterKeyHandler(keyHandlerToken) host.unregisterOwnerMoveListener(moveListenerToken) host.unregisterOutsidePressListener(outsidePressToken) @@ -487,30 +534,25 @@ internal class TaoPopupSceneLayerLinux( */ private fun updateNativeFrame() { if (_bounds == IntRect.Zero || released) return + drawBounds = popupDrawBounds(_bounds, _density.density) val origin = host.parentScreenOriginPx val offset = host.coordinateOffset - val frameInParent = - IntRect( - left = _bounds.left + offset.x, - top = _bounds.top + offset.y, - right = _bounds.right + offset.x, - bottom = _bounds.bottom + 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 = _bounds.translate(offset) + val frameInParent = drawBounds.translate(offset) val geometry = host.popupScreenGeometry - val clamp = popupScreenClampOffset(frameInParent, geometry) + 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 = - IntRect( - left = it.parentContentOriginPx.x + frameInParent.left + clamp.x, - top = it.parentContentOriginPx.y + frameInParent.top + clamp.y, - right = it.parentContentOriginPx.x + frameInParent.right + clamp.x, - bottom = it.parentContentOriginPx.y + frameInParent.bottom + clamp.y, - ), + frameOnScreenPx = frameInParent.translate(onScreen), + contentOnScreenPx = contentInParent.translate(onScreen), clampOffsetPx = clamp, panelHandle = popupWindow.handle, ), @@ -522,8 +564,8 @@ internal class TaoPopupSceneLayerLinux( // `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) + val w = alignToBufferScale(drawBounds.width, bufferScale) + val h = alignToBufferScale(drawBounds.height, bufferScale) popupWindow.setOuterPosition((xPx / scale).toDouble(), (yPx / scale).toDouble()) popupWindow.setInnerSize((w / scale).toDouble(), (h / scale).toDouble()) if (w != widthPx || h != heightPx) { @@ -552,7 +594,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). @@ -621,9 +664,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, @@ -634,7 +687,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) { 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 19c35d5dd..4f9991864 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 @@ -34,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. @@ -123,10 +124,16 @@ internal class TaoPopupSceneLayerWindows( */ private val dialogContainerSize: IntSize get() = - host.parentWindowSize.let { + 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) private var widthPx: Int = 1 private var heightPx: Int = 1 @@ -223,10 +230,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 @@ -313,6 +351,7 @@ 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) { updateNativeFrame() @@ -354,6 +393,10 @@ internal class TaoPopupSceneLayerWindows( get() = scrimColorState.value set(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 @@ -372,6 +415,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) @@ -440,6 +484,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() @@ -470,13 +515,7 @@ internal class TaoPopupSceneLayerWindows( 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, - ) + val nextDrawBounds = popupDrawBounds(_bounds, _density.density) val changed = nextDrawBounds != drawBounds drawBounds = nextDrawBounds widthPx = drawBounds.width.coerceAtLeast(1) @@ -505,28 +544,22 @@ internal class TaoPopupSceneLayerWindows( if (panelHandle == 0L) return if (drawBounds == IntRect.Zero || _bounds == IntRect.Zero) return val offset = host.coordinateOffset - val frameInParent = - IntRect( - left = drawBounds.left + offset.x, - top = drawBounds.top + offset.y, - right = drawBounds.right + offset.x, - bottom = drawBounds.bottom + 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 = _bounds.translate(offset) + val frameInParent = drawBounds.translate(offset) val geometry = host.popupScreenGeometry - val clamp = popupScreenClampOffset(frameInParent, geometry) + 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 = - IntRect( - left = it.parentContentOriginPx.x + finalX, - top = it.parentContentOriginPx.y + finalY, - right = it.parentContentOriginPx.x + finalX + frameInParent.width, - bottom = it.parentContentOriginPx.y + finalY + frameInParent.height, - ), + frameOnScreenPx = frameInParent.translate(onScreen), + contentOnScreenPx = contentInParent.translate(onScreen), clampOffsetPx = clamp, panelHandle = panelHandle, ), 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/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 10e90c6de..0921bb2f8 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 @@ -49,6 +49,7 @@ 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 @@ -61,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 @@ -466,9 +469,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. @@ -768,6 +769,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` @@ -916,6 +951,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 @@ -932,6 +968,8 @@ internal class TaoComposeSceneHost( override val exceptionHandler: WindowExceptionHandler? get() = outer.exceptionHandler + override val popupScrims: PopupScrimRegistry get() = outer.popupScrims + override fun requestRedraw() = outer.window.requestRedraw() override fun registerRenderer( @@ -1549,6 +1587,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 8d05d0cb7..2c8d7b49d 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 @@ -55,6 +55,7 @@ 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 @@ -174,6 +175,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 / @@ -562,9 +597,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. @@ -2159,6 +2192,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) @@ -2203,6 +2237,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( 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 478ed764f..962e0a465 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 @@ -50,6 +50,7 @@ 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 @@ -63,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 @@ -256,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 @@ -462,9 +498,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 @@ -1605,6 +1639,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 = @@ -1626,6 +1661,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..d6b2e63cf 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 } 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 efdf846bb..b1ee68b8a 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 @@ -127,6 +127,14 @@ class TaoSceneTestBatteryDriftTest { "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..0eeb34ff8 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt @@ -0,0 +1,403 @@ +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(), + 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 samples: List, + ) { + val visible: List get() = samples.filter { it.dialogTop != null } + 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 + } + + /** 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") + for (s in samples) { + appendLine( + " %5d %6d %4s %6s %8d".format( + s.tMs, + s.scrimRed, + s.dialogTop?.toString() ?: "-", + s.dialogBottom?.toString() ?: "-", + s.blueness, + ), + ) + } + } + + fun summary(): String = + "firstVisible=${firstVisibleMs}ms settled=${settledMs}ms slideIn=${slideInPx}px " + + "scrimRamp=$scrimRamp finalScrimRed=$finalScrimRed finalBlueness=$finalBlueness" + } + + private val measured = HashMap() + private val measuredTranslated = HashMap() + private val dialogShown = mutableStateOf(false) + private val translatedShown = mutableStateOf(false) + + @Composable + private fun Content() { + Box(Modifier.fillMaxSize().background(Color.White)) + val shown by dialogShown + if (shown) { + Dialog(onDismissRequest = { }) { + Box(Modifier.size(DIALOG_W_DP.dp, DIALOG_H_DP.dp).background(DIALOG_COLOR)) + } + } + } + + /** 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): TaoWindowTestCase = + TaoWindowTestCase( + name = "dialog appearance filmed — ${if (native) "native popup layer" else "in-scene layer"}", + skip = ::skipReason, + nativePopupLayers = native, + content = { 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) + } + } + settle(WARMUP_MILLIS) + val shownNs = System.nanoTime() + dialogShown.value = true + try { + settle(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) }, + ) + measured[native] = curve + val mode = 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(): TaoWindowTestCase = + TaoWindowTestCase( + name = "dialog appearance — native popup layer matches the in-scene layer", + skip = { skipReason() ?: if (measured.size < 2) "both filming cases must run first" else null }, + content = { Content() }, + ) { + val inScene = requireNotNull(measured[false]) + val native = requireNotNull(measured[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)" + } + } + near("first visible (ms)", inScene.firstVisibleMs, native.firstVisibleMs, FIRST_VISIBLE_TOLERANCE_MS) + near("settled (ms)", inScene.settledMs, native.settledMs, 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) + 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 MAX_FRAMES = 200 + private const val DUMP_UNTIL_MS = 260L + 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 index 02c0141f7..010c08f44 100644 --- 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 @@ -12,6 +12,7 @@ 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 @@ -80,6 +81,7 @@ internal object NativePopupPlacementHeadfulCases { nativeWindowRectMatchesTheClampedFrame(), dialogStaysCentredInItsWindow(), dialogNearTheScreenEdgeIsStillClamped(), + dialogSurfaceCoversItsShadow(), ) // ── 1. no gratuitous shifting ───────────────────────────────────────── @@ -149,9 +151,9 @@ internal object NativePopupPlacementHeadfulCases { // 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.frameOnScreenPx.left > windowRight) { + check(record.contentOnScreenPx.left > windowRight) { "popup must be allowed outside the owner window: " + - "frame=${record.frameOnScreenPx} windowRight=$windowRight" + "content=${record.contentOnScreenPx} windowRight=$windowRight" } check(record.clampOffsetPx == IntOffset.Zero) { "nothing to clamp here — the popup is off the window, not off the screen; " + @@ -181,7 +183,7 @@ internal object NativePopupPlacementHeadfulCases { val work = workArea() val tallDp = (work.height / scale()).toInt() + OVERSIZE_SLACK_DP val record = openPopup(heightDp = tallDp) - val frame = record.frameOnScreenPx + 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) { @@ -234,8 +236,8 @@ internal object NativePopupPlacementHeadfulCases { // — 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.frameOnScreenPx.width >= minWidthPx) { - "popup collapsed toward the owner window size: ${record.frameOnScreenPx}" + check(record.contentOnScreenPx.width >= minWidthPx) { + "popup collapsed toward the owner window size: ${record.contentOnScreenPx}" } } @@ -323,9 +325,15 @@ internal object NativePopupPlacementHeadfulCases { 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() - check(actual.top >= work.top && actual.bottom <= work.bottom) { - "the OS placed the popup outside the work area: $actual vs $work" + 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 @@ -351,7 +359,9 @@ internal object NativePopupPlacementHeadfulCases { dialogShown.value = true try { val record = awaitSettledRecord() - val frame = record.frameOnScreenPx + // 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() @@ -372,6 +382,43 @@ internal object NativePopupPlacementHeadfulCases { } } + 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", @@ -418,6 +465,7 @@ internal object NativePopupPlacementHeadfulCases { private val popupRequest = mutableStateOf(null) private val dropdownExpanded = mutableStateOf(false) private val dialogShown = mutableStateOf(false) + private val dialogShadow = mutableStateOf(false) @Composable private fun PopupSlot() { @@ -459,9 +507,15 @@ internal object NativePopupPlacementHeadfulCases { @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).background(Color.Cyan)) + 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), + ) } } } @@ -521,12 +575,16 @@ internal object NativePopupPlacementHeadfulCases { // ── Assertions ──────────────────────────────────────────────────────── - /** The #569 contract: the popup is fully inside its display's work area. */ + /** + * 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.frameOnScreenPx + val frame = record.contentOnScreenPx val areas = TaoMonitors.all(window).map { it.workAreaPx } check(areas.any { frame.fitsIn(it) }) { - "popup landed outside every work area: frame=$frame areas=$areas " + + "popup landed outside every work area: content=$frame areas=$areas " + "clamp=${record.clampOffsetPx} composeBounds=${record.boundsInWindowPx}" } } @@ -546,7 +604,7 @@ internal object NativePopupPlacementHeadfulCases { } private fun TaoWindowTestScope.describe(record: PopupFrameRecord): String = - "frame=${record.frameOnScreenPx} clamp=${record.clampOffsetPx} " + + "frame=${record.frameOnScreenPx} content=${record.contentOnScreenPx} clamp=${record.clampOffsetPx} " + "composeBounds=${record.boundsInWindowPx} window=${bounds()?.toList()} " + "work=${workArea()} scale=${scale()}" @@ -651,6 +709,7 @@ internal object NativePopupPlacementHeadfulCases { 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 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 65a80f893..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 @@ -368,6 +368,7 @@ public object TaoHeadfulTestSuiteMain { 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/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)) + } +} From 38e2cf626723d1a804920a93d74b5df7f2ef9dc3 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Sat, 5 Sep 2026 22:08:05 +0300 Subject: [PATCH 3/7] test(tao): film the dialog's disappearance too, under a heavier scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The appearance film now also records the hide animation and counts grabs that repeat the previous frame during either animation — dropped frames show up as a stall count the native layer must not exceed. The owner window carries forty rows of text so its per-frame present costs something. The first-visible check is one-sided: the native surface legitimately shows its first frame before the owner's next present. --- .../headful/DialogAppearanceHeadfulCases.kt | 110 ++++++++++++++++-- 1 file changed, 102 insertions(+), 8 deletions(-) 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 index 0eeb34ff8..a2271732c 100644 --- 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 @@ -60,9 +60,61 @@ internal object DialogAppearanceHeadfulCases { ) internal class Curve( - val samples: List, + 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) + } + + /** 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 @@ -98,8 +150,8 @@ internal object DialogAppearanceHeadfulCases { fun table(): String = buildString { - appendLine(" t(ms) scrimR top bottom blueness") - for (s in samples) { + 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, @@ -113,8 +165,9 @@ internal object DialogAppearanceHeadfulCases { } fun summary(): String = - "firstVisible=${firstVisibleMs}ms settled=${settledMs}ms slideIn=${slideInPx}px " + - "scrimRamp=$scrimRamp finalScrimRed=$finalScrimRed finalBlueness=$finalBlueness" + "show: firstVisible=${firstVisibleMs}ms settled=${settledMs}ms slideIn=${slideInPx}px " + + "scrimRamp=$scrimRamp finalScrimRed=$finalScrimRed finalBlueness=$finalBlueness " + + "stalls=$showStalls | hide: start=${hideStartMs}ms gone=${hideGoneMs}ms stalls=$hideStalls" } private val measured = HashMap() @@ -124,7 +177,18 @@ internal object DialogAppearanceHeadfulCases { @Composable private fun Content() { - Box(Modifier.fillMaxSize().background(Color.White)) + // 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 = { }) { @@ -252,8 +316,12 @@ internal object DialogAppearanceHeadfulCases { 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() @@ -265,6 +333,7 @@ internal object DialogAppearanceHeadfulCases { frames .filter { (ns, _) -> ns >= shownNs } .map { (ns, img) -> sample((ns - shownNs) / 1_000_000, img) }, + hideAtMs = (hiddenNs - shownNs) / 1_000_000, ) measured[native] = curve val mode = if (native) "native" else "in-scene" @@ -333,12 +402,33 @@ internal object DialogAppearanceHeadfulCases { problems += "$what: in-scene=$a native=$b (tolerance $tolerance)" } } - near("first visible (ms)", inScene.firstVisibleMs, native.firstVisibleMs, FIRST_VISIBLE_TOLERANCE_MS) + // 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("settled (ms)", inScene.settledMs, native.settledMs, 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) + 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 ") @@ -392,8 +482,12 @@ internal object DialogAppearanceHeadfulCases { 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 MAX_FRAMES = 200 - private const val DUMP_UNTIL_MS = 260L + 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 From 62b5fac5ae370e509e13222f46b4fbce24ae3abe Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Sat, 5 Sep 2026 22:22:28 +0300 Subject: [PATCH 4/7] fix(tao): keep a native dialog's surface where the dialog was while it fades out (#569) Dialog.skiko.kt's disappearance swaps the layer's content for an empty Layout that replays the recorded picture, and Compose then reports a zero-size boundsInWindow at the window centre for the whole fade-out. An in-scene layer draws into the window canvas and does not care; the native surface followed the bounds and shrank to a 32 dp square around a point, so a closing dialog collapsed and vanished instead of fading out. Each layer now sizes and places its surface on the last non-empty bounds. The appearance film gains the Material 3 AlertDialog nucleus-demo opens, a warm-up before filming, a duration-based comparison, and the smallest height the dialog spanned while fading out. --- decorated-window-tao/build.gradle.kts | 2 + .../window/tao/popup/TaoPopupSceneLayer.kt | 26 +++- .../tao/popup/TaoPopupSceneLayerLinux.kt | 22 +++- .../tao/popup/TaoPopupSceneLayerWindows.kt | 30 +++-- .../headful/DialogAppearanceHeadfulCases.kt | 124 ++++++++++++++++-- 5 files changed, 169 insertions(+), 35 deletions(-) diff --git a/decorated-window-tao/build.gradle.kts b/decorated-window-tao/build.gradle.kts index a24f4c6ec..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 { 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 082f007f2..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 @@ -155,6 +155,17 @@ internal class TaoPopupSceneLayer( */ 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 @@ -425,6 +436,7 @@ internal class TaoPopupSceneLayer( get() = _bounds set(value) { _bounds = value + if (!value.isEmpty) contentBounds = value updateNativeFrame() host.requestRedraw() } @@ -444,10 +456,10 @@ internal class TaoPopupSceneLayer( * what the scene draws in and what [scenePosition] maps pointers back to. */ private fun updateNativeFrame() { - if (_bounds == IntRect.Zero || disposed) return - drawBounds = popupDrawBounds(_bounds, _density.density) + if (contentBounds.isEmpty || disposed) return + drawBounds = popupDrawBounds(contentBounds, _density.density) val offset = host.coordinateOffset - val contentInParent = _bounds.translate(offset) + val contentInParent = contentBounds.translate(offset) val frameInParent = drawBounds.translate(offset) val geometry = host.popupScreenGeometry val clamp = popupScreenClampOffset(contentInParent, geometry) @@ -465,10 +477,10 @@ internal class TaoPopupSceneLayer( PopupNativeBridge.nativeSetInteractiveRegions( panelHandle, floatArrayOf( - (_bounds.left - drawBounds.left).toFloat(), - (_bounds.top - drawBounds.top).toFloat(), - _bounds.width.toFloat(), - _bounds.height.toFloat(), + (contentBounds.left - drawBounds.left).toFloat(), + (contentBounds.top - drawBounds.top).toFloat(), + contentBounds.width.toFloat(), + contentBounds.height.toFloat(), ), 1, ) 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 7ba02e887..a92c4cdf0 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 @@ -120,6 +120,17 @@ internal class TaoPopupSceneLayerLinux( */ 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 + /** EGL attachment ready — flips on WINDOW_READY once the GPU side is up. */ private var attachment: Long = 0 private var directContext: DirectContext? = null @@ -296,7 +307,7 @@ internal class TaoPopupSceneLayerLinux( host.popupScrims.register(rendererToken) { scrimColorState.value } host.registerKeyHandler(keyHandlerToken) { dispatchKey(it) } host.registerOwnerMoveListener(moveListenerToken) { - if (_bounds != IntRect.Zero) updateNativeFrame() + if (!contentBounds.isEmpty) updateNativeFrame() } } @@ -361,7 +372,7 @@ 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() host.requestRedraw() } @@ -385,6 +396,7 @@ internal class TaoPopupSceneLayerLinux( get() = _bounds set(value) { _bounds = value + if (!value.isEmpty) contentBounds = value updateNativeFrame() host.requestRedraw() } @@ -533,14 +545,14 @@ internal class TaoPopupSceneLayerLinux( * No-op on Wayland, where the host reports no screen geometry. */ private fun updateNativeFrame() { - if (_bounds == IntRect.Zero || released) return - drawBounds = popupDrawBounds(_bounds, _density.density) + if (contentBounds.isEmpty || released) return + drawBounds = popupDrawBounds(contentBounds, _density.density) val origin = host.parentScreenOriginPx val offset = host.coordinateOffset // 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 = _bounds.translate(offset) + val contentInParent = contentBounds.translate(offset) val frameInParent = drawBounds.translate(offset) val geometry = host.popupScreenGeometry val clamp = popupScreenClampOffset(contentInParent, geometry) 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 4f9991864..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 @@ -135,6 +135,17 @@ internal class TaoPopupSceneLayerWindows( * 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 @@ -353,7 +364,7 @@ internal class TaoPopupSceneLayerWindows( 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() } } @@ -379,6 +390,7 @@ internal class TaoPopupSceneLayerWindows( get() = _bounds set(value) { _bounds = value + if (!value.isEmpty) contentBounds = value updateDrawBoundsFromBounds() host.requestRedraw() } @@ -514,8 +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 = popupDrawBounds(_bounds, _density.density) + if (contentBounds.isEmpty) return false + val nextDrawBounds = popupDrawBounds(contentBounds, _density.density) val changed = nextDrawBounds != drawBounds drawBounds = nextDrawBounds widthPx = drawBounds.width.coerceAtLeast(1) @@ -542,12 +554,12 @@ internal class TaoPopupSceneLayerWindows( */ 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 // 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 = _bounds.translate(offset) + val contentInParent = contentBounds.translate(offset) val frameInParent = drawBounds.translate(offset) val geometry = host.popupScreenGeometry val clamp = popupScreenClampOffset(contentInParent, geometry) @@ -571,10 +583,10 @@ internal class TaoPopupSceneLayerWindows( 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/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt index a2271732c..8277f0b44 100644 --- 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 @@ -42,6 +42,9 @@ internal object DialogAppearanceHeadfulCases { 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(), @@ -84,6 +87,21 @@ internal object DialogAppearanceHeadfulCases { ?.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) @@ -128,6 +146,14 @@ internal object DialogAppearanceHeadfulCases { 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() { @@ -165,12 +191,15 @@ internal object DialogAppearanceHeadfulCases { } fun summary(): String = - "show: firstVisible=${firstVisibleMs}ms settled=${settledMs}ms slideIn=${slideInPx}px " + + "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 stalls=$hideStalls" + "stalls=$showStalls | hide: start=${hideStartMs}ms gone=${hideGoneMs}ms " + + "minHeight=${hideMinHeightRatio?.let { "%.2f".format(it) }} stalls=$hideStalls" } - private val measured = HashMap() + /** Keyed by (material, native). */ + private val measured = HashMap, Curve>() private val measuredTranslated = HashMap() private val dialogShown = mutableStateOf(false) private val translatedShown = mutableStateOf(false) @@ -197,6 +226,51 @@ internal object DialogAppearanceHeadfulCases { } } + /** + * 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() { @@ -280,12 +354,17 @@ internal object DialogAppearanceHeadfulCases { } } - private fun film(native: Boolean): TaoWindowTestCase = + private fun film( + native: Boolean, + material: Boolean = false, + ): TaoWindowTestCase = TaoWindowTestCase( - name = "dialog appearance filmed — ${if (native) "native popup layer" else "in-scene layer"}", + name = + "${if (material) "Material 3 AlertDialog" else "dialog"} appearance filmed — " + + "${if (native) "native popup layer" else "in-scene layer"}", skip = ::skipReason, nativePopupLayers = native, - content = { Content() }, + 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. @@ -313,6 +392,12 @@ internal object DialogAppearanceHeadfulCases { 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 @@ -335,8 +420,8 @@ internal object DialogAppearanceHeadfulCases { .map { (ns, img) -> sample((ns - shownNs) / 1_000_000, img) }, hideAtMs = (hiddenNs - shownNs) / 1_000_000, ) - measured[native] = curve - val mode = if (native) "native" else "in-scene" + 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() } @@ -378,14 +463,23 @@ internal object DialogAppearanceHeadfulCases { check(curve.firstVisibleMs != null) { "the dialog never showed up on screen; ${curve.summary()}" } } - private fun compare(): TaoWindowTestCase = + private fun compare(material: Boolean = false): TaoWindowTestCase = TaoWindowTestCase( - name = "dialog appearance — native popup layer matches the in-scene layer", - skip = { skipReason() ?: if (measured.size < 2) "both filming cases must run first" else null }, + 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[false]) - val native = requireNotNull(measured[true]) + 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() @@ -414,13 +508,14 @@ internal object DialogAppearanceHeadfulCases { problems += "first visible (ms): in-scene=$inSceneFirst native=$nativeFirst (tolerance $FIRST_VISIBLE_TOLERANCE_MS)" } - near("settled (ms)", inScene.settledMs, native.settledMs, SETTLE_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}" @@ -486,6 +581,7 @@ internal object DialogAppearanceHeadfulCases { 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 From 6174813e34f2bb50fa8643d9904968f68e84d242 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 5 Sep 2026 22:52:02 +0300 Subject: [PATCH 5/7] feat(tao): host the Compose context menu flyout in a native popup surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows and Linux context menu flyouts drew inside the window's render target whenever the window ran without nativePopupLayers, so a menu opened near an edge was clipped by the window like any in-scene popup. An OS-looking menu has to leave the window like the menus it imitates, and the application's choice for its other popups must not decide that. nativePopupLayers is a whole-scene switch (platform vs canvas layers), so a per-popup opt-in needs the seam Compose 1.12 actually uses: Popup picks its layer through LocalComposeSceneContext. A friend-package Java accessor reaches that internal local without reflection; NativePopupLayers { } then provides, for its subtree only, the window scene's own context with createLayer routed to the window's native popup layer factory — the same factory attach() uses when nativePopupLayers is on. The context menu representation wraps the Windows and Linux flyouts in it; macOS stays on NSMenu. --- .../window/jewel/JewelDecoratedWindow.kt | 2 + .../material2/MaterialDecoratedWindow.kt | 2 + .../material/MaterialDecoratedWindow.kt | 2 + .../api/decorated-window-tao.api | 8 + .../scene/TaoComposeSceneContextAccess.java | 31 ++++ .../window/tao/DecoratedWindow.kt | 15 ++ .../window/tao/NativePopupLayers.kt | 78 ++++++++ .../tao/scene/TaoComposeSceneContext.kt | 20 ++- .../window/tao/scene/TaoComposeSceneHost.kt | 36 ++-- .../tao/scene/TaoComposeSceneHostLinux.kt | 36 ++-- .../tao/scene/TaoComposeSceneHostWindows.kt | 33 ++-- .../window/tao/NativePopupLayersTest.kt | 170 ++++++++++++++++++ .../src/main/kotlin/com/example/demo/Main.kt | 2 +- .../application/DecoratedWindow.kt | 6 +- .../NativeContextMenuRepresentation.kt | 12 +- 15 files changed, 402 insertions(+), 51 deletions(-) create mode 100644 decorated-window-tao/src/main/java/androidx/compose/ui/scene/TaoComposeSceneContextAccess.java create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativePopupLayers.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NativePopupLayersTest.kt 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 7add30d0a..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). 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/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/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 0921bb2f8..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 @@ -420,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 @@ -428,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. @@ -437,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() }, @@ -943,6 +932,25 @@ internal class TaoComposeSceneHost( ) } + /** + * 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 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 2c8d7b49d..5761c5707 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 @@ -571,18 +571,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 { @@ -2178,8 +2167,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. 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 962e0a465..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 @@ -468,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 { @@ -1630,6 +1620,25 @@ internal class TaoComposeSceneHostWindows( ) } + /** + * 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 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/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt index b696019b1..c6303df5d 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,7 +148,7 @@ fun main(args: Array) = title = "Nucleus Demo", minimumSize = DpSize(1300.dp, 480.dp), nativeContextMenu = true, - nativePopupLayers = true + nativePopupLayers = false ) { CompositionLocalProvider( LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr, 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) { From 817f5e8eaacb25ddc634d272e25e6d91bd99c416 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 02:19:48 +0300 Subject: [PATCH 6/7] fix(tao): make the Linux context menu behave like the OS menus it imitates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the menu got wrong on a Linux desktop, found by driving a real right click against a nested GNOME Shell and reading back both screenshots and the app's own trace (scripts/context-menu-wayland-e2e.py, with the fixture it drives in nucleus-application's tests). A menu opened near the bottom of the screen was cut off. On native Wayland a client cannot know where its own window is, so it cannot keep a popup on screen by itself — the X11 clamp of #569 has nothing to work with there. The popup layer now maps as an xdg_popup instead of a wl_subsurface and lets the compositor place it: it flips above the pointer when there is no room below and slides along an edge, which is what GTK's own menus do. The tao patch carries the anchor point, the surface size and the shadow margins in one request, because GDK builds the positioner from the window's geometry as it stands at map time — a popup still sized 1x1 there asks the compositor to constrain a 1x1 rectangle and is never flipped. One popup per parent takes that path (an xdg_popup must be its parent's topmost popup); a dialog keeps the subsurface, since it belongs to its window rather than to the display. A second right click only closed the menu instead of moving it. The press that dismisses a popup is delivered to the scene in the same turn as the dismissal, so Compose's contextMenuOpenDetector — disabled while the menu is open — was still disabled when the press arrived, and the press did nothing. The host now recomposes and re-lays-out the scene between the two, so the detector is listening again by the time it sees the press. The menu also appeared a beat late: the layer painted its first frame only on the owner window's next redraw, though that first render is what measures the content and puts the popup on screen at all. It renders as soon as its GPU side is up. Measured against the fixture's trace, press to first present is now 40 ms steady, 108 ms for the first menu of a session. --- .../nucleusframework/window/tao/TaoWindow.kt | 49 +- .../window/tao/ffi/NativeTaoBridge.kt | 18 + .../window/tao/popup/TaoPopupDiagnostics.kt | 11 + .../window/tao/popup/TaoPopupHostLinux.kt | 13 + .../tao/popup/TaoPopupSceneLayerLinux.kt | 88 +++- .../tao/scene/TaoComposeSceneHostLinux.kt | 44 ++ .../window/tao/scene/TaoSceneBundle.kt | 20 + .../src/main/native/src/event_loop.rs | 38 ++ .../src/main/native/src/events.rs | 13 + .../src/main/native/src/window_jni.rs | 28 ++ .../native/vendor/tao/src/platform/unix.rs | 9 + .../tao/src/platform_impl/linux/event_loop.rs | 81 +++ .../tao/src/platform_impl/linux/window.rs | 39 ++ .../window/tao/TaoSceneTestBattery.kt | 12 + .../tao/TaoSceneTestBatteryDriftTest.kt | 1 + nucleus-application/build.gradle.kts | 22 + .../contextmenu/ContextMenuE2EMain.kt | 159 ++++++ scripts/context-menu-wayland-e2e.py | 476 ++++++++++++++++++ 18 files changed, 1116 insertions(+), 5 deletions(-) create mode 100644 nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuE2EMain.kt create mode 100755 scripts/context-menu-wayland-e2e.py 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/popup/TaoPopupDiagnostics.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt index e336d81dd..1d5d38d2d 100644 --- 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 @@ -73,8 +73,19 @@ internal object TaoPopupDiagnostics { 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/TaoPopupHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt index b81721746..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 @@ -149,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/TaoPopupSceneLayerLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt index a92c4cdf0..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 @@ -46,6 +46,8 @@ 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 /** @@ -131,6 +133,13 @@ internal class TaoPopupSceneLayerLinux( */ 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 @@ -299,7 +308,11 @@ internal class TaoPopupSceneLayerLinux( 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() @@ -363,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 @@ -373,6 +387,11 @@ internal class TaoPopupSceneLayerLinux( } // Re-push any frame set before the window was ready, and paint. 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() } @@ -395,6 +414,7 @@ internal class TaoPopupSceneLayerLinux( override var boundsInWindow: IntRect get() = _bounds set(value) { + trace { "boundsInWindow=$value" } _bounds = value if (!value.isEmpty) contentBounds = value updateNativeFrame() @@ -431,11 +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 @@ -578,8 +600,36 @@ internal class TaoPopupSceneLayerLinux( // logical size below an exact integer for GTK. val w = alignToBufferScale(drawBounds.width, bufferScale) val h = alignToBufferScale(drawBounds.height, bufferScale) - popupWindow.setOuterPosition((xPx / scale).toDouble(), (yPx / scale).toDouble()) - popupWindow.setInnerSize((w / scale).toDouble(), (h / scale).toDouble()) + 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 @@ -589,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 @@ -620,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() @@ -721,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/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index 5761c5707..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 @@ -216,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() @@ -257,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() @@ -1671,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 @@ -2007,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) @@ -2292,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 + } } } @@ -2577,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/TaoSceneBundle.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt index d6b2e63cf..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 @@ -156,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/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/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 b1ee68b8a..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, 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/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)) From 71eaa9133698609035ae82a6df0378360095c429 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 6 Sep 2026 09:03:02 +0300 Subject: [PATCH 7/7] chore: unblock ktlint so the headful suite can run (baseline probe) --- examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c6303df5d..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,7 +148,7 @@ fun main(args: Array) = title = "Nucleus Demo", minimumSize = DpSize(1300.dp, 480.dp), nativeContextMenu = true, - nativePopupLayers = false + nativePopupLayers = false, ) { CompositionLocalProvider( LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr,