Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@ package androidx.compose.ui.interaction

import androidx.compose.foundation.ComposeFoundationFlags
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.contextmenu.builder.item
import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents
import androidx.compose.foundation.text.contextmenu.modifier.filterTextContextMenuComponents
Expand All @@ -40,10 +46,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.UIKitInstrumentedTest
import androidx.compose.ui.test.assertVisibleInContainer
Expand All @@ -53,11 +62,19 @@ import androidx.compose.ui.test.findNodeWithTag
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.horizontalDistanceTo
import androidx.compose.ui.test.utils.isLoupeView
import androidx.compose.ui.test.utils.union
import androidx.compose.ui.test.utils.up
import androidx.compose.ui.test.utils.verticalDistanceTo
import androidx.compose.ui.test.waitForContextMenu
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.PlatformImeOptions
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.toDpRect
import androidx.compose.ui.unit.dp
import kotlin.test.Ignore
import kotlin.test.Test
Expand Down Expand Up @@ -247,6 +264,102 @@ class TextFieldEditMenuTest {
findNodeWithLabel("Paste").assertVisibleInContainer()
}

@Test
@Ignore // CMP-10315: Context menu is positioned far from the caret for BTF1.
fun testBasicTextFieldContextMenuIsPositionedNearCaret() =
runTextFieldContextMenuPositionTest(EditableTextFieldKind.BasicTextField)

@Test
fun testBasicTextField2ContextMenuIsPositionedNearCaret() =
runTextFieldContextMenuPositionTest(EditableTextFieldKind.BasicTextField2)

private fun runTextFieldContextMenuPositionTest(textFieldKind: EditableTextFieldKind) {
for (newContextMenuEnabled in arrayOf(false, true)) {
runContextMenuTest(newContextMenuEnabled) {
UIPasteboard.generalPasteboard().string = "Paste text"
val layoutInfo = setOffsetTextFieldContent(textFieldKind)

waitUntil("Text field should be laid out") {
layoutInfo.textFieldFrame != null && layoutInfo.textLayoutResult != null
}

longPressNodeWithTagAndAwaitContextMenu("TextField")

assertContextMenuNearCaret(
caretFrame = layoutInfo.caretFrameInWindow(density),
textFieldKind = textFieldKind,
newContextMenuEnabled = newContextMenuEnabled
)
}
}
}

private fun UIKitInstrumentedTest.setOffsetTextFieldContent(
textFieldKind: EditableTextFieldKind,
): TextFieldLayoutInfo {
val text = "I am a TextField"
val keyboardOptions = KeyboardOptions(
platformImeOptions = PlatformImeOptions {
usingNativeTextInput(false)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it's default value, not needed

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}
)
val focusRequester = FocusRequester()

fun offsetTextFieldModifier(layoutInfo: TextFieldLayoutInfo): Modifier =
Modifier
.width(160.dp)
.height(24.dp)
.onGloballyPositioned { coordinates ->
layoutInfo.textFieldFrame = coordinates.boundsInWindow().toDpRect(density)
}
.then(textFieldModifier(focusRequester))

val initialSelection = TextRange(text.length, text.length)
val textFieldValue = mutableStateOf(TextFieldValue(text, initialSelection))
val textFieldState = TextFieldState(text, initialSelection)

val layoutInfo = when (textFieldKind) {
EditableTextFieldKind.BasicTextField -> TextFieldLayoutInfo(
selectionOffset = { textFieldValue.value.selection.start }
)
EditableTextFieldKind.BasicTextField2 -> TextFieldLayoutInfo(
selectionOffset = { textFieldState.selection.start }
)
}

setContent {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can be omitted

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

.safeDrawingPadding()
.padding(start = 80.dp, top = 48.dp)
) {
when (textFieldKind) {
EditableTextFieldKind.BasicTextField -> BasicTextField(
value = textFieldValue.value,
onValueChange = { textFieldValue.value = it },
modifier = offsetTextFieldModifier(layoutInfo),
keyboardOptions = keyboardOptions,
onTextLayout = { layoutInfo.textLayoutResult = it }
)
EditableTextFieldKind.BasicTextField2 -> BasicTextField(
state = textFieldState,
modifier = offsetTextFieldModifier(layoutInfo),
keyboardOptions = keyboardOptions,
onTextLayout = { getResult ->
layoutInfo.textLayoutResult = getResult()
}
)
}
}
}

focusRequester.requestFocus()
waitForIdle()
return layoutInfo
}

@Test
fun testBasicTextField2LongPressShowsContextMenu() = runUIKitInstrumentedTest {
UIPasteboard.generalPasteboard().string = "Paste text"
Expand Down Expand Up @@ -757,6 +870,67 @@ class TextFieldEditMenuTest {
waitForContextMenu()
}

private fun UIKitInstrumentedTest.assertContextMenuNearCaret(
caretFrame: DpRect,
textFieldKind: EditableTextFieldKind,
newContextMenuEnabled: Boolean,
) {
val menuFrame = findContextMenuItemsFrame()
val horizontalDistance = menuFrame.horizontalDistanceTo(caretFrame)
val verticalDistance = menuFrame.verticalDistanceTo(caretFrame)
val contextMenu = if (newContextMenuEnabled) "new menu" else "old menu"
val maxDistance = 16.dp

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: better to extract into constant

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


assertTrue(
horizontalDistance <= maxDistance,
"Context menu is horizontally more than $maxDistance from caret. " +
"textFieldKind: $textFieldKind, contextMenu: $contextMenu, " +
"horizontal distance: $horizontalDistance."
)
assertTrue(
verticalDistance <= maxDistance,
"Context menu is vertically more than $maxDistance from caret. " +
"textFieldKind: $textFieldKind, contextMenu: $contextMenu, " +
"vertical distance: $verticalDistance."
)
}

private fun UIKitInstrumentedTest.findContextMenuItemsFrame(): DpRect {
// Paste is always present in the menu, so its visibility means the menu is shown
verifyContextMenuItemsVisible(listOf("Paste"))

val pasteFrame = findNodeWithLabel("Paste").frame!!
val itemFrames = listOf("Select", "Select All").mapNotNull { label ->

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the purpose is to find a menu frame and assert that it is not far away from the caret, why not reuse finding_UIEditMenuContainerView (like it is done in waitForContextMenu()) instead of collecting and uniting frames of the items in this menu?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

findNodeWithLabelOrNull(label)?.frame
}

return pasteFrame.union(itemFrames)
}

private fun TextLayoutResult.cursorFrameInWindow(
textFieldFrame: DpRect,
offset: Int,
density: Density,
): DpRect {
val cursorFrame = getCursorRect(offset).toDpRect(density)
return DpRect(
left = textFieldFrame.left + cursorFrame.left,
top = textFieldFrame.top + cursorFrame.top,
right = textFieldFrame.left + cursorFrame.right,
bottom = textFieldFrame.top + cursorFrame.bottom
)
}

private fun TextFieldLayoutInfo.caretFrameInWindow(density: Density): DpRect {
val textFieldFrame = textFieldFrame ?: error("TextField frame is null")
val textLayoutResult = textLayoutResult ?: error("TextLayoutResult is null")
return textLayoutResult.cursorFrameInWindow(
textFieldFrame = textFieldFrame,
offset = selectionOffset(),
density = density
)
}

private fun UIKitInstrumentedTest.setTextFieldContent(
textFieldKind: EditableTextFieldKind,
initialValue: TextFieldValue,
Expand Down Expand Up @@ -800,6 +974,13 @@ class TextFieldEditMenuTest {
BasicTextField2
}

private class TextFieldLayoutInfo(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's possible to wait, then I suggest waiting for that PR to merge, I've made an API for that there

It would be also possible to use UIKitInstrumentedTest.setTextFieldContent (line 934)
instead of the UIKitInstrumentedTest.setOffsetTextFieldContent (line 297)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thanks, let's wait

val selectionOffset: () -> Int,
) {
var textFieldFrame: DpRect? = null
var textLayoutResult: TextLayoutResult? = null
}

private companion object {
private const val PARTIAL_SELECTION_TEXT = "accomplishment extraordinary magnificent establishment"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,41 @@ internal fun DpRect.intersect(other: DpRect): DpRect {
)
}

/**
* Returns the smallest rectangle containing this rectangle and all of [others].
*/
internal fun DpRect.union(others: Iterable<DpRect>): DpRect =
others.fold(this) { bounds, other ->
DpRect(
left = min(bounds.left, other.left),
top = min(bounds.top, other.top),
right = max(bounds.right, other.right),
bottom = max(bounds.bottom, other.bottom)
)
}

/**
* Returns the horizontal gap between this rectangle and [other],
* or `0.dp` if their horizontal ranges overlap.
*/
internal fun DpRect.horizontalDistanceTo(other: DpRect): Dp =
when {
right < other.left -> other.left - right
left > other.right -> left - other.right
else -> 0.dp
}

/**
* Returns the vertical gap between this rectangle and [other],
* or `0.dp` if their vertical ranges overlap.
*/
internal fun DpRect.verticalDistanceTo(other: DpRect): Dp =
when {
bottom < other.top -> other.top - bottom
top > other.bottom -> top - other.bottom
else -> 0.dp
}

@OptIn(ExperimentalForeignApi::class)
internal fun UIView.dpRectInWindow() = convertRect(bounds, toView = null).toDpRect()
internal fun<T> List<T>.forEachWithPrevious(block: (T, T) -> Unit) {
Expand Down
Loading