From bca2ec885d59e06a3d80a7ad23bc4a02c638cb12 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Tue, 18 Aug 2026 07:41:09 +0300 Subject: [PATCH] fix(contextmenu): dismiss the flyout when the owning window loses focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking to open the OS-looking context menu and then clicking into another application left the menu on screen. Each backend detects outside clicks by watching its own process only. On Windows the popup HWND uses a thread-local WH_MOUSE hook (deliberately, to replace a SetCapture monitor that made the parent receive WM_KILLFOCUS and flipped WindowInfo.isWindowFocused false). On Linux the scene layer is told about outside presses by the parent window's own pointer input, so a press that never lands on our surface is never reported — on X11 and Wayland alike, and Wayland forbids watching another surface's clicks by design. The global XI2 root monitor belongs to the standalone popup host (tray popups), not to this flyout. No backend dismissed on a focus change with no click behind it either (Alt+Tab, the taskbar, a notification stealing activation). Window focus is the one signal every backend delivers, so dismiss on the focused -> unfocused transition. LocalWindowInfo is read from the parent scene: inside Popup the layer publishes its own WindowInfo with isWindowFocused pinned to true. A leading unfocused run is dropped so a backend that has not reported focus yet when the menu opens cannot dismiss it immediately -- the flyout counterpart of the not-yet-active accessory app fixed for NSMenu in a62349e2. macOS is unaffected: that path is a real NSMenu and already dismisses itself. --- .../contextmenu/ContextMenuFlyout.kt | 45 +++++++++++++++++++ .../ContextMenuFocusDismissTest.kt | 32 +++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFocusDismissTest.kt diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFlyout.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFlyout.kt index 03cdb547e..276e44e4e 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFlyout.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFlyout.kt @@ -34,7 +34,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -46,6 +48,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign @@ -59,6 +62,10 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.rememberPopupPositionProviderAtPosition import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map private const val SUBMENU_OPEN_DELAY_MS = 200L private const val SUBMENU_CLOSE_DELAY_MS = 160L @@ -116,6 +123,7 @@ internal fun ContextMenuFlyout( ) { val dark = isSystemInDarkTheme() val menuDensity = LocalContextMenuDensity.current ?: LocalDensity.current + DismissOnWindowFocusLoss(onDismiss) Popup( popupPositionProvider = rememberPopupPositionProviderAtPosition(status.rect.center), onDismissRequest = onDismiss, @@ -132,6 +140,43 @@ internal fun ContextMenuFlyout( } } +/** + * Closes the menu as soon as the owning window loses focus. + * + * The flyout is a native popup surface whose outside-click monitor only + * observes this process, so a click that activates another application never + * reaches it: on Windows `WH_MOUSE` is a thread-local hook, and on Linux the + * scene layer is told about outside presses by the *parent window's* own + * pointer input (`TaoPopupHostLinux.registerOutsidePressListener`) — Wayland + * has no way to watch another surface's clicks at all. Window focus is the + * one signal every backend does deliver, and it also covers dismissals with + * no click behind them (Alt+Tab, the taskbar, a notification stealing + * activation), which is what the OS menus do. + * + * Reads [LocalWindowInfo] from the *parent* scene: inside [Popup] the layer + * publishes its own `WindowInfo` with `isWindowFocused` pinned to `true`. + */ +@Composable +private fun DismissOnWindowFocusLoss(onDismiss: () -> Unit) { + val windowInfo = LocalWindowInfo.current + val currentOnDismiss by rememberUpdatedState(onDismiss) + LaunchedEffect(windowInfo) { + snapshotFlow { windowInfo.isWindowFocused } + .windowFocusLosses() + .collect { currentOnDismiss() } + } +} + +/** + * Emits once per focused → unfocused transition, ignoring a leading unfocused + * run so a backend that has not yet reported focus when the menu opens does + * not dismiss it immediately. + */ +internal fun Flow.windowFocusLosses(): Flow = + dropWhile { focused -> !focused } + .filter { focused -> !focused } + .map { } + @Composable private fun ContextMenuFlyoutSurface( entries: List, diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFocusDismissTest.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFocusDismissTest.kt new file mode 100644 index 000000000..b67c44020 --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFocusDismissTest.kt @@ -0,0 +1,32 @@ +package dev.nucleusframework.application.contextmenu + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContextMenuFocusDismissTest { + private fun losses(vararg focused: Boolean): Int = + runBlocking { flowOf(*focused.toTypedArray()).windowFocusLosses().toList().size } + + @Test + fun `losing focus while open dismisses once`() { + assertEquals(1, losses(true, false)) + } + + @Test + fun `staying focused never dismisses`() { + assertEquals(0, losses(true, true, true)) + } + + @Test + fun `a backend that has not reported focus yet does not dismiss`() { + assertEquals(0, losses(false, false, true)) + } + + @Test + fun `each focus loss after a regain dismisses again`() { + assertEquals(2, losses(true, false, true, false)) + } +}