From f65e70169387aaf768b3c689118f53fbc20ecba7 Mon Sep 17 00:00:00 2001 From: Stream Date: Sat, 13 Jun 2026 23:36:24 +0800 Subject: [PATCH 1/2] fix(desktop): edge case on touch screen --- .../compose/ui/awt/AwtEventFilter.desktop.kt | 5 +++ .../ui/scene/ComposeSceneMediator.desktop.kt | 32 +++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/AwtEventFilter.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/AwtEventFilter.desktop.kt index f26ec2d4796b8..0313c71d084dd 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/AwtEventFilter.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/AwtEventFilter.desktop.kt @@ -62,6 +62,11 @@ internal object OnlyValidPrimaryMouseButtonFilter : AwtEventFilter() { private var isPrimaryButtonPressed = false override fun shouldSendMouseEvent(event: MouseEvent): Boolean { + // Touchscreen scroll can arrive as MOUSE_WHEEL with BUTTON1_DOWN_MASK while the + // finger is touching the screen. Wheel events are not button state transitions. + if (event.id == MouseEvent.MOUSE_WHEEL) { + return true + } val eventReportsPrimaryButtonPressed = (event.modifiersEx and MouseEvent.BUTTON1_DOWN_MASK) != 0 if ((event.button == MouseEvent.BUTTON1) && diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt index 57f7a80cf4e63..f75b54f0e57af 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt @@ -972,20 +972,38 @@ private val MouseEvent.buttons get() = PointerButtons( // info about the pressed mouse button when using touchpad on MacOS 12 (AWT only). // When the [Tap to click] feature is activated on Mac OS 12, half of all clicks are not // handled because [event.modifiersEx] may not provide info about the pressed mouse button. - isPrimaryPressed = ((modifiersEx and MouseEvent.BUTTON1_DOWN_MASK) != 0 + isPrimaryPressed = (isButtonDown(MouseEvent.BUTTON1) || (id == MouseEvent.MOUSE_PRESSED && button == MouseEvent.BUTTON1)) && !isMacOsCtrlClick, - isSecondaryPressed = (modifiersEx and MouseEvent.BUTTON3_DOWN_MASK) != 0 + isSecondaryPressed = isButtonDown(MouseEvent.BUTTON3) || (id == MouseEvent.MOUSE_PRESSED && button == MouseEvent.BUTTON3) || isMacOsCtrlClick, - isTertiaryPressed = (modifiersEx and MouseEvent.BUTTON2_DOWN_MASK) != 0 + isTertiaryPressed = isButtonDown(MouseEvent.BUTTON2) || (id == MouseEvent.MOUSE_PRESSED && button == MouseEvent.BUTTON2), - isBackPressed = (modifiersEx and MouseEvent.getMaskForButton(4)) != 0 + isBackPressed = isButtonDown(4) || (id == MouseEvent.MOUSE_PRESSED && button == 4), - isForwardPressed = (modifiersEx and MouseEvent.getMaskForButton(5)) != 0 + isForwardPressed = isButtonDown(5) || (id == MouseEvent.MOUSE_PRESSED && button == 5), ) +/** + * Returns whether AWT reports [button] as currently pressed, with one correction for release events. + * + * In normal mouse input, `modifiersEx` carries `BUTTON*_DOWN_MASK` while the button is down, so + * press, drag, move with a pressed button, and wheel while the button is held all return `true`. + * A release of a different button also keeps returning `true` for [button] if its mask is still set. + * + * Some AWT paths, including touchscreen taps on X11/JBR, can send `MOUSE_RELEASED` for [button] + * while still leaving that button's down mask in `modifiersEx`. For that event, the release itself + * is the more specific state transition, so this helper treats [button] as no longer pressed. + * + * This helper intentionally does not repair the opposite case where `MOUSE_PRESSED` has no down + * mask; callers handle that by also checking `id == MOUSE_PRESSED && button == ...`. + */ +private fun MouseEvent.isButtonDown(button: Int): Boolean = + (modifiersEx and MouseEvent.getMaskForButton(button)) != 0 && + !(id == MouseEvent.MOUSE_RELEASED && this.button == button) + private val MouseEvent.keyboardModifiers get() = PointerKeyboardModifiers( isCtrlPressed = (modifiersEx and InputEvent.CTRL_DOWN_MASK) != 0, isMetaPressed = (modifiersEx and InputEvent.META_DOWN_MASK) != 0, @@ -1002,11 +1020,13 @@ private val MouseEvent.keyboardModifiers get() = PointerKeyboardModifiers( private fun Component.subscribeToMouseEvents(mouseAdapter: MouseAdapter) { addMouseListener(mouseAdapter) addMouseMotionListener(mouseAdapter) + addMouseWheelListener(mouseAdapter) } private fun Component.unsubscribeFromMouseEvents(mouseAdapter: MouseAdapter) { removeMouseListener(mouseAdapter) removeMouseMotionListener(mouseAdapter) + removeMouseWheelListener(mouseAdapter) } private fun getLockingKeyStateSafe( @@ -1020,6 +1040,6 @@ private fun getLockingKeyStateSafe( private val MouseEvent.isMacOsCtrlClick get() = ( hostOs.isMacOS && - ((modifiersEx and InputEvent.BUTTON1_DOWN_MASK) != 0) && + isButtonDown(MouseEvent.BUTTON1) && ((modifiersEx and InputEvent.CTRL_DOWN_MASK) != 0) ) From 9090b7884296f256769c04eadad75717c04a75e0 Mon Sep 17 00:00:00 2001 From: Stream Date: Sat, 13 Jun 2026 23:36:43 +0800 Subject: [PATCH 2/2] test(desktop): edge case on touch screen --- .../kotlin/androidx/compose/ui/TestUtils.kt | 31 ++++++++- .../compose/ui/window/WindowInputEventTest.kt | 68 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt index b633d6054a801..c62ff2f8c9545 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt @@ -174,12 +174,13 @@ fun Container.sendMouseRelease( button: Int = MouseEvent.BUTTON1, x: Int, y: Int, + modifiers: Int = 0, ): Boolean { return sendMouseEvent( id = MouseEvent.MOUSE_RELEASED, x = x, y = y, - modifiers = 0, + modifiers = modifiers, button = button ) } @@ -239,6 +240,34 @@ fun Container.sendMouseWheelEvent( return event.isConsumed } +fun Window.sendMouseWheelEventToFocusOwner( + x: Int = width / 2, + y: Int = height / 2, + scrollType: Int = MouseWheelEvent.WHEEL_UNIT_SCROLL, + wheelRotation: Double = 0.0, + modifiers: Int = 0, +): Boolean { + val component = mostRecentFocusOwner!! + val event = MouseWheelEvent( + component, + MouseWheelEvent.MOUSE_WHEEL, + 0, + modifiers, + x, + y, + x, + y, + 1, + false, + scrollType, + 1, + wheelRotation.toInt(), + wheelRotation + ) + component.dispatchEvent(event) + return event.isConsumed +} + private val EventComponent = object : Component() {} internal fun awtWheelEvent(isScrollByPages: Boolean = false) = MouseWheelEvent( diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowInputEventTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowInputEventTest.kt index 32d8283449b73..973980abe7fb1 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowInputEventTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowInputEventTest.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.window +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -51,6 +52,7 @@ import androidx.compose.ui.sendMouseEvent import androidx.compose.ui.sendMousePress import androidx.compose.ui.sendMouseRelease import androidx.compose.ui.sendMouseWheelEvent +import androidx.compose.ui.sendMouseWheelEventToFocusOwner import androidx.compose.ui.unit.dp import com.google.common.truth.Truth.assertThat import java.awt.Dimension @@ -362,6 +364,34 @@ class WindowInputEventTest { assertThat(events[4].position).isEqualTo(Offset(80 * density, 30 * density)) } + @Test + fun `clickable fires when mouse release keeps released button modifier`() = + runApplicationTest { + lateinit var window: ComposeWindow + var clicks = 0 + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 200.dp, height = 100.dp) + ) { + window = this.window + + Box(Modifier.fillMaxSize().clickable { clicks++ }) + } + } + + awaitIdle() + + window.sendMousePress(BUTTON1, 100, 50) + awaitIdle() + + window.sendMouseRelease(BUTTON1, 100, 50, modifiers = BUTTON1_DOWN_MASK) + awaitIdle() + + assertThat(clicks).isEqualTo(1) + } + @Test fun `catch mouse move`() = runApplicationTest { lateinit var window: ComposeWindow @@ -462,6 +492,44 @@ class WindowInputEventTest { assertThat(deltas.last()).isEqualTo(Offset(0f, -1f)) } + @Test + fun `catch mouse scroll dispatched to focus owner`() = runApplicationTest { + lateinit var window: ComposeWindow + + val deltas = mutableListOf() + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 200.dp, height = 100.dp) + ) { + window = this.window + + Box( + Modifier + .fillMaxSize() + .onPointerEvent(PointerEventType.Scroll) { + deltas.add(it.changes.first().scrollDelta) + } + ) + } + } + + awaitIdle() + assertThat(deltas.size).isEqualTo(0) + + window.sendMouseWheelEventToFocusOwner( + 100, + 50, + WHEEL_UNIT_SCROLL, + wheelRotation = 1.0, + modifiers = BUTTON1_DOWN_MASK, + ) + awaitIdle() + assertThat(deltas.size).isEqualTo(1) + assertThat(deltas.last()).isEqualTo(Offset(0f, 1f)) + } + @Test fun `catch multiple scroll events in one frame`() = runApplicationTest { lateinit var window: ComposeWindow