From 08b6919397ef008929db489550c8b4521fb91bda Mon Sep 17 00:00:00 2001 From: Alexander Maryanovsky Date: Wed, 20 Mar 2024 13:02:50 +0200 Subject: [PATCH] Re-show the tooltip on mouse-move following a click in TooltipArea. --- .../compose/foundation/TooltipArea.desktop.kt | 37 ++-- .../compose/foundation/TooltipAreaTest.kt | 177 ++++++++++++++++-- 2 files changed, 172 insertions(+), 42 deletions(-) diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/TooltipArea.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/TooltipArea.desktop.kt index 2264c2ae23540..9e6dcaf77d935 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/TooltipArea.desktop.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/TooltipArea.desktop.kt @@ -26,9 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow @@ -37,6 +35,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.areAnyPressed import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -44,7 +43,6 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.rememberPopupPositionProviderAtPosition -import androidx.compose.ui.window.rememberCursorPositionProvider import androidx.compose.ui.window.rememberComponentRectPositionProvider import kotlinx.coroutines.delay import kotlinx.coroutines.Job @@ -105,14 +103,15 @@ fun TooltipArea( content: @Composable () -> Unit ) { var parentBounds by remember { mutableStateOf(Rect.Zero) } - var popupPosition by remember { mutableStateOf(Offset.Zero) } var cursorPosition by remember { mutableStateOf(Offset.Zero) } var isVisible by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() var job: Job? by remember { mutableStateOf(null) } fun startShowing() { - job?.cancel() + if (job?.isActive == true) { // Don't restart the job if it's already active + return + } job = scope.launch { delay(delayMillis.toLong()) isVisible = true @@ -121,6 +120,7 @@ fun TooltipArea( fun hide() { job?.cancel() + job = null isVisible = false } @@ -135,19 +135,21 @@ fun TooltipArea( .onGloballyPositioned { parentBounds = it.boundsInWindow() } .onPointerEvent(PointerEventType.Enter) { cursorPosition = it.position - startShowing() + if (!isVisible && !it.buttons.areAnyPressed) { + startShowing() + } } .onPointerEvent(PointerEventType.Move) { cursorPosition = it.position - hideIfNotHovered(parentBounds.topLeft + it.position) + if (!isVisible && !it.buttons.areAnyPressed) { + startShowing() + } } .onPointerEvent(PointerEventType.Exit) { hideIfNotHovered(parentBounds.topLeft + it.position) } - .pointerInput(Unit) { - detectDown { - hide() - } + .onPointerEvent(PointerEventType.Press) { + hide() } ) { content() @@ -157,6 +159,7 @@ fun TooltipArea( popupPositionProvider = tooltipPlacement.positionProvider(cursorPosition), onDismissRequest = { isVisible = false } ) { + var popupPosition by remember { mutableStateOf(Offset.Zero) } Box( Modifier .onGloballyPositioned { popupPosition = it.positionInWindow() } @@ -191,18 +194,6 @@ private fun Modifier.onPointerEvent( } } -private suspend fun PointerInputScope.detectDown(onDown: (Offset) -> Unit) { - while (true) { - awaitPointerEventScope { - val event = awaitPointerEvent(PointerEventPass.Initial) - val down = event.changes.find { it.changedToDown() } - if (down != null) { - onDown(down.position) - } - } - } -} - /** * An interface for providing a [PopupPositionProvider] for the tooltip. */ diff --git a/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/TooltipAreaTest.kt b/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/TooltipAreaTest.kt index ce0ef08ac807f..e252c0953c859 100644 --- a/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/TooltipAreaTest.kt +++ b/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/TooltipAreaTest.kt @@ -18,48 +18,187 @@ package androidx.compose.foundation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import org.junit.Rule +import kotlin.test.assertFalse import org.junit.Test @OptIn(ExperimentalFoundationApi::class, ExperimentalTestApi::class) internal class TooltipAreaTest { - @get:Rule - val rule = createComposeRule() // https://github.com/JetBrains/compose-jb/issues/2821 @Test - fun `simple tooltip is shown`(): Unit = runBlocking(Dispatchers.Main) { - rule.setContent { + fun `simple tooltip is shown`() = runComposeUiTest { + setContent { + SimpleTooltipArea() + } + + onNodeWithTag("tooltip").assertDoesNotExist() + + onNodeWithTag("elementWithTooltip").performMouseInput { + moveTo(Offset(30f, 40f)) + } + + onNodeWithTag("tooltip").assertExists() + } + + /** + * Verify that the tooltip is hidden when the tooltip area is pressed. + */ + @Test + fun tooltipHiddenOnPress() = runComposeUiTest { + setContent { + SimpleTooltipArea() + } + + onNodeWithTag("elementWithTooltip").performMouseInput { + moveTo(Offset(30f, 40f)) + } + onNodeWithTag("tooltip").assertExists() + + onNodeWithTag("elementWithTooltip").performMouseInput { + press() + } + onNodeWithTag("tooltip").assertDoesNotExist() + } + + /** + * Verify that the tooltip is hidden when the mouse leaves the tooltip area. + */ + @Test + fun tooltipHiddenOnExit() = runComposeUiTest { + setContent { + SimpleTooltipArea() + } + + onNodeWithTag("elementWithTooltip").performMouseInput { + moveTo(Offset(30f, 40f)) + } + onNodeWithTag("tooltip").assertExists() + + onNodeWithTag("elementWithTooltip").performMouseInput { + moveTo(Offset(150f, 150f)) + } + onNodeWithTag("tooltip").assertDoesNotExist() + } + + /** + * Verify that the tooltip is hidden when the mouse moves into the tooltip, as long as it's + * still also inside the tooltip. + */ + @Test + fun tooltipNotHiddenOnMoveIntoTooltip() = runComposeUiTest { + var tooltipHidden = false + setContent { TooltipArea( tooltip = { - Box { - BasicText( - text = "Tooltip", - modifier = Modifier.testTag("tooltipText") - ) + Box(Modifier.size(100.dp).testTag("tooltip")) + DisposableEffect(Unit) { + onDispose { + println("Tooltip disposed") + tooltipHidden = true + } } - } + }, + tooltipPlacement = TooltipPlacement.CursorPoint( + offset = DpOffset(x = 0.dp, y = 10.dp) + ), ) { - BasicText("Text", modifier = Modifier.size(50.dp).testTag("elementWithTooltip")) + Box(Modifier.size(100.dp).testTag("elementWithTooltip")) } } - rule.onNodeWithTag("elementWithTooltip").performMouseInput { + // Move into the tooltip area + onNodeWithTag("elementWithTooltip").performMouseInput { moveTo(Offset(30f, 40f)) } - rule.waitForIdle() - rule.onNodeWithTag("tooltipText").assertExists() + // Move into the tooltip, but still inside the area + onNodeWithTag("tooltip").let { + it.assertExists() + it.performMouseInput { + moveTo(Offset(10f, 10f)) // Still inside the tooltip area + } + } + waitForIdle() + + // Can't test with `assertExists` because if the tooltip was hidden, it could still be + // re-shown after a delay. So the test would pass even on the wrong behavior. + assertFalse(tooltipHidden, "Tooltip was hidden on move into tooltip") + + // Move within the tooltip to a position outside the tooltip area + onNodeWithTag("tooltip").let { + it.assertExists() + it.performMouseInput { + moveTo(Offset(99f, 99f)) // Outside the tooltip area + } + } + onNodeWithTag("tooltip").assertDoesNotExist() + } + + @Test + fun tooltipShownAfterDelay() = runComposeUiTest { + mainClock.autoAdvance = false + + setContent { + SimpleTooltipArea(delayMillis = 200) + } + + onNodeWithTag("elementWithTooltip").performMouseInput { + moveTo(Offset(30f, 40f)) + } + mainClock.advanceTimeBy(100) + onNodeWithTag("tooltip").assertDoesNotExist() + mainClock.advanceTimeBy(101) + onNodeWithTag("tooltip").assertExists() + } + + @Test + fun tooltipReshownOnMove() = runComposeUiTest { + setContent { + SimpleTooltipArea() + } + + onNodeWithTag("elementWithTooltip").performMouseInput { + moveTo(Offset(30f, 40f)) + } + onNodeWithTag("tooltip").assertExists() + + onNodeWithTag("elementWithTooltip").performMouseInput { + press() + } + onNodeWithTag("tooltip").assertDoesNotExist() + + onNodeWithTag("elementWithTooltip").performMouseInput { + release() + moveBy(Offset(10f, 10f)) + } + onNodeWithTag("tooltip").assertExists() + } + + @Composable + private fun SimpleTooltipArea( + areaSize: Dp = 100.dp, + tooltipSize: Dp = 20.dp, + delayMillis: Int = 500 + ) { + TooltipArea( + tooltip = { + Box(Modifier.size(tooltipSize).testTag("tooltip")) + }, + delayMillis = delayMillis + ) { + Box(Modifier.size(areaSize).testTag("elementWithTooltip")) + } } } \ No newline at end of file