diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt new file mode 100644 index 0000000000000..6f1740a5d1822 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -0,0 +1,409 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.interaction + +import androidx.compose.foundation.ComposeFoundationFlags +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.text.selection.SelectionState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.assertVisibleInContainer +import androidx.compose.ui.test.findFocusedUITextInput +import androidx.compose.ui.test.findNodeWithLabel +import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.findNodeWithTagOrNull +import androidx.compose.ui.test.firstNodeOrNull +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.tapContextMenuButton +import androidx.compose.ui.test.utils.findFirstDescendant +import androidx.compose.ui.test.utils.hold +import androidx.compose.ui.test.utils.isLoupeView +import androidx.compose.ui.test.utils.up +import androidx.compose.ui.test.waitForContextMenu +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import org.jetbrains.skiko.OS +import org.jetbrains.skiko.OSVersion +import org.jetbrains.skiko.available +import platform.UIKit.UIPasteboard + +class SelectionContainerInteractionTest { + + @Test + fun testSelectionContainer_LongPressSelectsWord() = runUIKitInstrumentedTest { + val selectionState = SelectionState() + val text = "accomplishment" + + setSelectionContainerContent(state = selectionState, text = text) + + longPressAndReleaseAfterLoupe(Tag) + + waitUntil("SelectionContainer should select the word after long press") { + selectionState.selectedText() == text + } + + assertEquals( + listOf(text), + selectionState.selectedTexts.map { it.text }, + ) + } + + @Test + fun testSelectionContainer_DoubleTapSelectsWord() = runUIKitInstrumentedTest { + val selectionState = SelectionState() + val text = "accomplishment" + + setSelectionContainerContent(state = selectionState, text = text) + + focusThenDoubleTap(Tag, delayMillis = DoubleTapPreparationDelayMillis) + waitUntil("SelectionContainer should select the word after double tap") { + selectionState.selectedText() == text + } + + assertEquals( + listOf(text), + selectionState.selectedTexts.map { it.text }, + ) + } + + @Test + fun testSelectionContainer_LongPressDragExtendsSelectionAcrossLines() = + runUIKitInstrumentedTest { + val selectionState = SelectionState() + val firstLine = "accomplishment" + + setSelectionContainerContent( + state = selectionState, + text = "$firstLine\nmagnificent", + contentWidth = 160.dp, + ) + + longPressAndDrag( + startTag = Tag, + endTag = Tag, + startXFraction = 0.12f, + startYFraction = 0.25f, + endXFraction = 0.80f, + endYFraction = 0.75f, + ) + + waitUntil("SelectionContainer should extend the selection across lines") { + val selectedText = selectionState.selectedText() + selectedText.contains("\n") && + selectedText.substringAfter('\n').isNotEmpty() + } + + val selectedText = selectionState.selectedText() + assertTrue( + selectedText.startsWith(firstLine), + "Expected selection to start from the first line, but got: $selectedText", + ) + } + + @Test + fun testSelectionContainer_LongPressDragExtendsSelectionAcrossMultipleBasicTexts() = + runUIKitInstrumentedTest { + val selectionState = SelectionState() + val firstText = "accomplishment" + val secondText = "magnificent" + + setSelectionContainerContent(state = selectionState) { + Column { + BasicText( + text = firstText, + modifier = Modifier.width(SelectableTextWidth).testTag(FirstTextTag), + ) + BasicText( + text = secondText, + modifier = Modifier.width(SelectableTextWidth).testTag(SecondTextTag), + ) + } + } + + longPressAndDrag( + startTag = FirstTextTag, + endTag = SecondTextTag, + startXFraction = 0.02f, + endXFraction = 0.98f, + ) + + waitUntil("SelectionContainer should extend selection across multiple BasicTexts") { + selectionState.selectedText() == firstText + secondText + } + + assertEquals( + listOf(firstText, secondText), + selectionState.selectedTexts.map { it.text }, + ) + } + + @Test + fun testSelectionContainer_LongPressDragSkipsDisableSelectionSubtree() = + runUIKitInstrumentedTest { + val selectionState = SelectionState() + val textBeforeDisabled = "accomplishment" + val textAfterDisabled = "remarkable" + val textAfterDisabledTag = "SelectionContainerTextAfterDisabled" + + setSelectionContainerContent(state = selectionState) { + Column { + BasicText( + text = textBeforeDisabled, + modifier = Modifier.width(SelectableTextWidth).testTag(FirstTextTag), + ) + DisableSelection { + BasicText( + text = "hidden", + modifier = Modifier.width(SelectableTextWidth), + ) + } + BasicText( + text = textAfterDisabled, + modifier = + Modifier.width(SelectableTextWidth).testTag(textAfterDisabledTag), + ) + } + } + + longPressAndDrag( + startTag = FirstTextTag, + endTag = textAfterDisabledTag, + startXFraction = 0.02f, + endXFraction = 0.98f, + ) + + waitUntil("SelectionContainer should skip DisableSelection content during drag") { + selectionState.selectedText() == textBeforeDisabled + textAfterDisabled + } + + assertEquals( + listOf(textBeforeDisabled, textAfterDisabled), + selectionState.selectedTexts.map { it.text }, + ) + } + + @Test + fun testSelectionContainer_CopyCopiesExactSelectedText() = + runSelectionContainerContextMenuTest { + UIPasteboard.generalPasteboard().string = "Clipboard sentinel" + val selectionState = SelectionState() + val firstWord = "copyable" + val text = "$firstWord second" + + setSelectionContainerContent(state = selectionState, text = text) + + awaitNodeLaidOut(Tag) + openToolbarForLeadingWord(Tag) + waitUntil("SelectionContainer should create the expected word selection before Copy") { + selectionState.selectedText() == firstWord + } + findNodeWithLabel("Copy").assertVisibleInContainer() + tapContextMenuButton("Copy") + + waitUntil("Pasteboard should contain the copied SelectionContainer text") { + UIPasteboard.generalPasteboard().string == firstWord + } + + val selectionAfterCopy = selectionState.selectedText() + assertTrue( + selectionAfterCopy.isEmpty() || selectionAfterCopy == firstWord, + "Expected SelectionContainer selection to either clear or preserve the copied word after Copy, but was: $selectionAfterCopy", + ) + } + + @Test + fun testSelectionContainer_EmptyTextDoesNotOpenMenu() = runSelectionContainerContextMenuTest { + val selectionState = SelectionState() + + setSelectionContainerContent(state = selectionState, text = "") + + awaitNodeLaidOut(Tag) + findNodeWithTag(Tag).longPress() + waitForIdle() + delay(EmptyTextLongPressSettleDelayMillis) + + assertTrue( + selectionState.selectedTexts.isEmpty(), + "Expected empty text content to produce no selection.", + ) + assertNoContextMenu() + } + + @Test + fun testSelectionContainer_OpeningMenuDoesNotShowKeyboard() = + runSelectionContainerContextMenuTest { + val selectionState = SelectionState() + + setSelectionContainerContent(state = selectionState, text = "copyable second") + + awaitNodeLaidOut(Tag) + focusThenDoubleTap(Tag, delayMillis = DoubleTapPreparationDelayMillis) + waitForContextMenu() + waitUntil("SelectionContainer should create a selection before menu open") { + selectionState.selectedTexts.isNotEmpty() + } + + findNodeWithLabel("Copy").assertVisibleInContainer() + assertNull( + findFocusedUITextInput(), + "Expected SelectionContainer menu to open without focusing a UITextInput.", + ) + assertEquals(0.dp, keyboardHeight) + } + + private fun UIKitInstrumentedTest.setSelectionContainerContent( + state: SelectionState, + content: @Composable () -> Unit, + ) { + setContent { + Box(modifier = Modifier.fillMaxSize()) { + SelectionContainer( + state = state, + modifier = Modifier.align(Alignment.Center).testTag(Tag), + ) { + content() + } + } + } + } + + private fun UIKitInstrumentedTest.setSelectionContainerContent( + state: SelectionState, + text: String, + contentWidth: Dp = 320.dp, + ) { + setSelectionContainerContent(state = state) { + BasicText(text = text, modifier = Modifier.width(contentWidth)) + } + } + + private fun UIKitInstrumentedTest.longPressAndReleaseAfterLoupe(tag: String) { + val touch = findNodeWithTag(tag).touchDown() + waitUntil("Selection loupe should appear after long press") { + findFirstDescendant { it.isLoupeView } != null + } + touch.up() + } + + private fun UIKitInstrumentedTest.awaitNodeLaidOut(tag: String) { + waitUntil("Node with tag $tag should be laid out") { + findNodeWithTagOrNull(tag)?.frame != null + } + } + + private fun UIKitInstrumentedTest.openToolbarForLeadingWord(tag: String) { + findNodeWithTag(tag).tap() + delay(DoubleTapPreparationDelayMillis) + val tapPoint = pointInNode(tag, xFraction = 0.1f, yFraction = 0.5f) + tap(tapPoint) + delay(ManualDoubleTapIntervalDelayMillis) + tap(tapPoint) + waitForContextMenu() + } + + @OptIn(ExperimentalFoundationApi::class) + private fun runSelectionContainerContextMenuTest(testBlock: UIKitInstrumentedTest.() -> Unit) = + runUIKitInstrumentedTest(params = listOf(false, true)) { newContextMenuEnabled -> + val previousValue = ComposeFoundationFlags.isNewContextMenuEnabled + ComposeFoundationFlags.isNewContextMenuEnabled = newContextMenuEnabled + try { + testBlock() + } finally { + ComposeFoundationFlags.isNewContextMenuEnabled = previousValue + } + } + + private fun UIKitInstrumentedTest.longPressAndDrag( + startTag: String, + endTag: String, + startXFraction: Float, + endXFraction: Float, + startYFraction: Float = 0.5f, + endYFraction: Float = 0.5f, + ) { + val startPoint = pointInNode(startTag, startXFraction, startYFraction) + val endPoint = pointInNode(endTag, endXFraction, endYFraction) + + val touch = touchDown(startPoint) + waitUntil("Selection loupe should appear after long press") { + findFirstDescendant { it.isLoupeView } != null + } + touch.hold() + delay(LongPressDragSettleDelayMillis) + touch.dragTo(x = endPoint.x, y = endPoint.y, duration = 0.3.seconds) + touch.up() + } + + private fun UIKitInstrumentedTest.pointInNode( + tag: String, + xFraction: Float, + yFraction: Float, + ): DpOffset { + val frame = findNodeWithTag(tag).frame!! + return DpOffset( + x = frame.left + (frame.right - frame.left) * xFraction, + y = frame.top + (frame.bottom - frame.top) * yFraction, + ) + } + + private fun UIKitInstrumentedTest.assertNoContextMenu() { + assertNull( + firstNodeOrNull { node -> + node.element?.let { it::class.simpleName } == if (available(OS.Ios to OSVersion(16))) { + "_UIEditMenuContainerView" + } else { + "UICalloutBar" + } + }, + "Expected no SelectionContainer context menu host to be present.", + ) + } + + private fun SelectionState.selectedText(): String = + selectedTexts.joinToString(separator = "") { it.text } + + private companion object { + private const val Tag = "SelectionContainer" + private const val FirstTextTag = "SelectionContainerFirstText" + private const val SecondTextTag = "SelectionContainerSecondText" + // Gives the first tap time to settle so the next doubleTap() is treated as a new gesture. + private const val DoubleTapPreparationDelayMillis = 500L + // Lets us observe that long-pressing empty text does not create a late menu or selection. + private const val EmptyTextLongPressSettleDelayMillis = 500L + // Keeps the two manual taps close enough for UIKit to recognize them as a double tap. + private const val ManualDoubleTapIntervalDelayMillis = 50L + // Gives long-press state a brief moment to settle before starting a drag extension. + private const val LongPressDragSettleDelayMillis = 100L + private val SelectableTextWidth = 160.dp + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt index 3ed04829cf51b..8a3a07a75003a 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt @@ -50,10 +50,9 @@ import androidx.compose.ui.test.assertVisibleInContainer import androidx.compose.ui.test.findNodeWithLabel import androidx.compose.ui.test.findNodeWithLabelOrNull import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.longPressAndAwaitContextMenu import androidx.compose.ui.test.runUIKitInstrumentedTest -import androidx.compose.ui.test.utils.findFirstDescendant -import androidx.compose.ui.test.utils.hold -import androidx.compose.ui.test.utils.isLoupeView +import androidx.compose.ui.test.tapContextMenuButton import androidx.compose.ui.test.utils.up import androidx.compose.ui.test.waitForContextMenu import androidx.compose.ui.text.TextRange @@ -63,13 +62,9 @@ import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds import kotlinx.cinterop.ExperimentalForeignApi -import org.jetbrains.skiko.OS -import org.jetbrains.skiko.OSVersion -import org.jetbrains.skiko.available import platform.UIKit.UIPasteboard class TextFieldEditMenuTest { @@ -742,23 +737,7 @@ class TextFieldEditMenuTest { } private fun UIKitInstrumentedTest.openToolbar(textFieldTag: String) { - findNodeWithTag(textFieldTag).tap() - delay(500) - findNodeWithTag(textFieldTag).doubleTap() - waitForContextMenu() - } - - private fun textFieldModifier(focusRequester: FocusRequester): Modifier = - Modifier - .testTag("TextField") - .focusRequester(focusRequester) - - private fun UIKitInstrumentedTest.longPressAndAwaitContextMenu(textFieldTag: String) { - val touch = findNodeWithTag(textFieldTag).touchDown() - waitUntil { - findFirstDescendant { it.isLoupeView } != null - } - touch.up() + focusThenDoubleTap(textFieldTag) waitForContextMenu() } @@ -846,18 +825,4 @@ class TextFieldEditMenuTest { private fun UIKitInstrumentedTest.verifyFullToolbarPresent() { verifyContextMenuItemsVisible(listOf("Cut", "Copy", "Paste", "Select All")) } - - private fun UIKitInstrumentedTest.tapContextMenuButton(label: String) { - if (available(OS.Ios to OSVersion(16))) { - findNodeWithLabel(label).tap() - } else { - // Because on iOS < 16 the context menu is shown in a separate window, - // it's not fully interactive with the default Tap action. - findNodeWithLabel(label) - .touchDown(useNodeWindow = true) - .hold() - .also { delay(100) } - .up() - } - } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 0646421cf2e17..a6bb6bf0cde5f 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -27,8 +27,10 @@ import androidx.compose.ui.test.utils.beginKeyPress import androidx.compose.ui.test.utils.beginModifierKeyPress import androidx.compose.ui.test.utils.beginPress import androidx.compose.ui.test.utils.center +import androidx.compose.ui.test.utils.findFirstDescendant import androidx.compose.ui.test.utils.getTouchesEvent import androidx.compose.ui.test.utils.hold +import androidx.compose.ui.test.utils.isLoupeView import androidx.compose.ui.test.utils.mouseDown import androidx.compose.ui.test.utils.moveToLocationOnWindow import androidx.compose.ui.test.utils.release @@ -511,6 +513,13 @@ internal class UIKitInstrumentedTest( return tap(frame.center()) } + fun focusThenDoubleTap(tag: String, delayMillis: Long = 500L) { + val node = findNodeWithTag(tag) + node.tap() + delay(delayMillis) + node.doubleTap() + } + /** * Simulates a touch-down event at the center of a given AccessibilityTestNode. */ @@ -756,4 +765,27 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() { } != null } delay(500) // wait for toolbar animation -} \ No newline at end of file +} + +internal fun UIKitInstrumentedTest.longPressAndAwaitContextMenu(tag: String) { + val touch = findNodeWithTag(tag).touchDown() + waitUntil{ + findFirstDescendant { it.isLoupeView } != null + } + touch.up() + waitForContextMenu() +} + +internal fun UIKitInstrumentedTest.tapContextMenuButton(label: String) { + if (available(OS.Ios to OSVersion(16))) { + findNodeWithLabel(label).tap() + } else { + // Because on iOS < 16 the context menu is shown in a separate window, + // it's not fully interactive with the default Tap action. + findNodeWithLabel(label) + .touchDown(useNodeWindow = true) + .hold() + .also { delay(100) } + .up() + } +}