blob: 249f1a42c8a7ea6ca6bb03155bcf9e46dbc0e781 [file] [log] [blame]
/*
* Copyright 2020 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.foundation.textfield
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.FocusedWindowTest
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.testutils.assertPixelColor
import androidx.compose.testutils.assertPixels
import androidx.compose.testutils.assertShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.WindowInfo
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.toOffset
import androidx.test.filters.FlakyTest
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
import kotlin.math.ceil
import kotlin.math.floor
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
@LargeTest
class TextFieldCursorTest : FocusedWindowTest {
private val motionDurationScale = object : MotionDurationScale {
override var scaleFactor: Float by mutableStateOf(1f)
}
@OptIn(ExperimentalTestApi::class)
@get:Rule
val rule = createComposeRule(effectContext = motionDurationScale).also {
it.mainClock.autoAdvance = false
}
private val boxPadding = 10.dp
private val cursorColor = Color.Red
private val textStyle = TextStyle(
color = Color.White,
background = Color.White,
fontSize = 10.sp
)
private val textFieldWidth = 40.dp
private val textFieldHeight = 20.dp
private val textFieldBgColor = Color.White
private var isFocused = false
private var cursorRect = Rect.Zero
private val bgModifier = Modifier.background(textFieldBgColor)
private val focusModifier = Modifier.onFocusChanged { if (it.isFocused) isFocused = true }
private val sizeModifier = Modifier.size(textFieldWidth, textFieldHeight)
// default TextFieldModifier
private val textFieldModifier = sizeModifier
.then(bgModifier)
.then(focusModifier)
// default onTextLayout to capture cursor boundaries.
private val onTextLayout: (TextLayoutResult) -> Unit = { cursorRect = it.getCursorRect(0) }
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun textFieldFocused_cursorRendered() = with(rule.density) {
rule.setTextFieldTestContent {
BasicTextField(
value = "",
onValueChange = {},
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = onTextLayout
)
}
focusAndWait()
rule.mainClock.advanceTimeBy(100)
with(rule.density) {
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun textFieldFocused_cursorWithBrush() = with(rule.density) {
rule.setTextFieldTestContent {
BasicTextField(
value = "",
onValueChange = {},
textStyle = textStyle.copy(fontSize = textStyle.fontSize * 2),
modifier = Modifier
.size(textFieldWidth, textFieldHeight * 2)
.then(bgModifier)
.then(focusModifier),
cursorBrush = Brush.verticalGradient(
// make a brush double/triple color at the beginning and end so we have stable
// colors at the ends.
// Without triple bottom, the bottom color never hits to the provided color.
listOf(
Color.Blue,
Color.Blue,
Color.Green,
Color.Green,
Color.Green
)
),
onTextLayout = onTextLayout
)
}
focusAndWait()
rule.mainClock.advanceTimeBy(100)
val bitmap = rule.onNode(hasSetTextAction())
.captureToImage().toPixelMap()
val cursorLeft = ceil(cursorRect.left).toInt() + 1
val cursorTop = ceil(cursorRect.top).toInt() + 1
val cursorBottom = floor(cursorRect.bottom).toInt() - 1
bitmap.assertPixelColor(Color.Blue, x = cursorLeft, y = cursorTop)
bitmap.assertPixelColor(Color.Green, x = cursorLeft, y = cursorBottom)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorPosition() = with(rule.density) {
val cursorOffset = 4
val textValue = mutableStateOf(TextFieldValue("test", selection = TextRange(cursorOffset)))
rule.setTextFieldTestContent {
Box(Modifier.padding(boxPadding)) {
BasicTextField(
value = textValue.value,
onValueChange = {},
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = { cursorRect = it.getCursorRect(cursorOffset) }
)
}
}
focusAndWait()
rule.mainClock.advanceTimeBy(100)
assertThat(cursorRect.left).isGreaterThan(0f)
assertThat(cursorRect.left).isLessThan(textFieldWidth.toPx())
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorPosition_rtl() = with(rule.density) {
val cursorOffset = 2
val textValue = mutableStateOf(
TextFieldValue(
"\u05D0\u05D1\u05D2",
selection = TextRange(cursorOffset)
)
)
rule.setTextFieldTestContent {
Box(Modifier.padding(boxPadding)) {
BasicTextField(
value = textValue.value,
onValueChange = {},
textStyle = textStyle.copy(textDirection = TextDirection.Rtl),
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = { cursorRect = it.getCursorRect(cursorOffset) }
)
}
}
focusAndWait()
rule.mainClock.advanceTimeBy(100)
assertThat(cursorRect.left).isGreaterThan(0f)
assertThat(cursorRect.left).isLessThan(textFieldWidth.toPx())
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorPosition_clamped() = with(rule.density) {
val cursorOffset = 20
val textValue = mutableStateOf(
TextFieldValue(
"test ",
selection = TextRange(cursorOffset)
)
)
rule.setTextFieldTestContent {
Box(Modifier.padding(boxPadding)) {
BasicTextField(
value = textValue.value,
onValueChange = {},
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = { cursorRect = it.getCursorRect(cursorOffset) }
)
}
}
focusAndWait()
rule.mainClock.advanceTimeBy(100)
// Cursor is beyond the right edge of the text field
assertThat(cursorRect.left).isGreaterThan(textFieldWidth.toPx())
val cursorWidth = 2.dp
val clampedCursorHorizontal = (textFieldWidth - cursorWidth).toPx()
val clampedCursorRect = Rect(
clampedCursorHorizontal,
cursorRect.top,
clampedCursorHorizontal,
cursorRect.bottom
)
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(cursorWidth, this, clampedCursorRect)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorPosition_rtl_clamped() = with(rule.density) {
val cursorOffset = 20
val textValue = mutableStateOf(
TextFieldValue(
"\u05D0\u05D1\u05D2 ",
selection = TextRange(cursorOffset)
)
)
rule.setTextFieldTestContent {
Box(Modifier.padding(boxPadding)) {
BasicTextField(
value = textValue.value,
onValueChange = {},
textStyle = textStyle.copy(textDirection = TextDirection.Rtl),
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = { cursorRect = it.getCursorRect(cursorOffset) }
)
}
}
focusAndWait()
rule.mainClock.advanceTimeBy(100)
// Cursor is beyond the left edge of the text field
assertThat(cursorRect.left).isLessThan(0f)
val clampedCursorRect = Rect(0f, cursorRect.top, 0f, cursorRect.bottom)
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, clampedCursorRect)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorPosition_textFieldThinnerThanCursor() = with(rule.density) {
val cursorWidth = 2.dp
val thinSizeModifier = Modifier.size(cursorWidth - 0.5.dp, textFieldHeight)
val thinTextFieldModifier = thinSizeModifier
.then(bgModifier)
.then(focusModifier)
rule.setTextFieldTestContent {
Box(Modifier.padding(boxPadding)) {
BasicTextField(
value = "",
onValueChange = {},
textStyle = textStyle,
modifier = thinTextFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = onTextLayout
)
}
}
focusAndWait()
rule.mainClock.advanceTimeBy(100)
assertThat(cursorRect.left).isEqualTo(0f)
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorBlinkingAnimation() = with(rule.density) {
rule.setTextFieldTestContent {
BasicTextField(
value = "",
onValueChange = {},
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = onTextLayout
)
}
focusAndWait()
// cursor visible first 500 ms
rule.mainClock.advanceTimeBy(100)
with(rule.density) {
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
// cursor invisible during next 500 ms
rule.mainClock.advanceTimeBy(700)
rule.onNode(hasSetTextAction())
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.White,
backgroundColor = Color.White,
shapeOverlapPixelCount = 0.0f
)
}
@Suppress("UnnecessaryOptInAnnotation")
@OptIn(ExperimentalTestApi::class)
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorBlinkingAnimation_whenSystemDisablesAnimations() = with(rule.density) {
motionDurationScale.scaleFactor = 0f
rule.setTextFieldTestContent {
BasicTextField(
value = "",
onValueChange = {},
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = onTextLayout
)
}
focusAndWait()
// cursor visible first 500 ms
rule.mainClock.advanceTimeBy(100)
with(rule.density) {
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
// cursor invisible during next 500 ms
rule.mainClock.advanceTimeBy(700)
rule.onNode(hasSetTextAction())
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.White,
backgroundColor = Color.White,
shapeOverlapPixelCount = 0.0f
)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorUnsetColor_noCursor() = with(rule.density) {
rule.setTextFieldTestContent {
BasicTextField(
value = "",
onValueChange = {},
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(Color.Unspecified)
)
}
focusAndWait()
// no cursor when usually shown
rule.onNode(hasSetTextAction())
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.White,
backgroundColor = Color.White,
shapeOverlapPixelCount = 0.0f
)
// no cursor when should be no cursor
rule.mainClock.advanceTimeBy(700)
rule.onNode(hasSetTextAction())
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.White,
backgroundColor = Color.White,
shapeOverlapPixelCount = 0.0f
)
}
@Ignore // b/271927667
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorNotBlinking_whileTyping() = with(rule.density) {
rule.setTextFieldTestContent {
val text = remember { mutableStateOf("test") }
BasicTextField(
value = text.value,
onValueChange = { text.value = it },
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = onTextLayout
)
}
focusAndWait()
// cursor visible first 500 ms
rule.mainClock.advanceTimeBy(500)
// TODO(b/170298051) check here that cursor is visible when we have a way to control
// cursor position when sending a text
// change text field value
rule.onNode(hasSetTextAction())
.performTextReplacement("")
// cursor would have been invisible during next 500 ms if cursor blinks while typing.
// To prevent blinking while typing we restart animation when new symbol is typed.
rule.mainClock.advanceTimeBy(400)
with(rule.density) {
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
}
@Test
@FlakyTest(bugId = 283292820)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun selectionChanges_cursorNotBlinking() = with(rule.density) {
rule.mainClock.autoAdvance = false
val textValue = mutableStateOf(TextFieldValue("test", selection = TextRange(2)))
rule.setTextFieldTestContent {
BasicTextField(
value = textValue.value,
onValueChange = { textValue.value = it },
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = onTextLayout
)
}
focusAndWait()
// hide the cursor
rule.mainClock.advanceTimeBy(500)
rule.mainClock.advanceTimeByFrame()
// TODO(b/170298051) check here that cursor is visible when we have a way to control
// cursor position when sending a text
rule.runOnIdle {
textValue.value = textValue.value.copy(selection = TextRange(0))
}
// necessary for animation to start (shows cursor again)
rule.mainClock.advanceTimeByFrame()
with(rule.density) {
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun brushChanged_doesntResetTimer() {
var cursorBrush by mutableStateOf(SolidColor(cursorColor))
rule.setTextFieldTestContent {
BasicTextField(
value = "",
onValueChange = {},
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = cursorBrush,
onTextLayout = onTextLayout
)
}
focusAndWait()
rule.mainClock.advanceTimeBy(800)
cursorBrush = SolidColor(Color.Green)
rule.mainClock.advanceTimeByFrame()
rule.onNode(hasSetTextAction())
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.White,
backgroundColor = Color.White,
shapeOverlapPixelCount = 0.0f
)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun cursorNotBlinking_whenWindowLostFocus() {
val focusWindow = mutableStateOf(true)
fun createWindowInfo(focused: Boolean) = object : WindowInfo {
override val isWindowFocused: Boolean
get() = focused
}
rule.setTextFieldTestContent {
CompositionLocalProvider(LocalWindowInfo provides createWindowInfo(focusWindow.value)) {
Box(Modifier.padding(boxPadding)) {
BasicTextField(
value = "",
onValueChange = { },
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = onTextLayout
)
}
}
}
focusAndWait()
// cursor visible first 500ms
rule.mainClock.advanceTimeBy(100)
with(rule.density) {
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
// window loses focus
focusWindow.value = false
rule.waitForIdle()
// check that text field cursor disappeared even within visible 500ms
rule.mainClock.advanceTimeBy(100)
rule.onNode(hasSetTextAction())
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.White,
backgroundColor = Color.White,
shapeOverlapPixelCount = 0.0f
)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun focusedTextField_resumeBlinking_whenWindowRegainsFocus() {
val focusWindow = mutableStateOf(true)
fun createWindowInfo(focused: Boolean) = object : WindowInfo {
override val isWindowFocused: Boolean
get() = focused
}
rule.setTextFieldTestContent {
CompositionLocalProvider(LocalWindowInfo provides createWindowInfo(focusWindow.value)) {
Box(Modifier.padding(boxPadding)) {
BasicTextField(
value = "",
onValueChange = { },
textStyle = textStyle,
modifier = textFieldModifier,
cursorBrush = SolidColor(cursorColor),
onTextLayout = onTextLayout
)
}
}
}
focusAndWait()
// window loses focus
focusWindow.value = false
rule.waitForIdle()
// check that text field cursor disappeared even within visible 500ms
rule.mainClock.advanceTimeBy(100)
rule.onNode(hasSetTextAction())
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.White,
backgroundColor = Color.White,
shapeOverlapPixelCount = 0.0f
)
// window regains focus within 500ms
focusWindow.value = true
rule.waitForIdle()
rule.mainClock.advanceTimeBy(100)
with(rule.density) {
rule.onNode(hasSetTextAction())
.captureToImage()
.assertCursor(2.dp, this, cursorRect)
}
}
private fun focusAndWait() {
rule.onNode(hasSetTextAction()).performClick()
rule.mainClock.advanceTimeUntil { isFocused }
}
private fun ImageBitmap.assertCursor(cursorWidth: Dp, density: Density, cursorRect: Rect) {
assertThat(cursorRect.height).isNotEqualTo(0f)
assertThat(cursorRect).isNotEqualTo(Rect.Zero)
val cursorWidthPx = (with(density) { cursorWidth.roundToPx() })
// assert cursor width is greater than 2 since we will shrink the check area by 1 on each
// side
assertThat(cursorWidthPx).isGreaterThan(2)
// shrink the check are by 1px for left, top, right, bottom
val checkRect = Rect(
ceil(cursorRect.left) + 1,
ceil(cursorRect.top) + 1,
floor(cursorRect.right) + cursorWidthPx - 1,
floor(cursorRect.bottom) - 1
)
// skip an expanded rectangle that is 1px larger than cursor rectangle
val skipRect = Rect(
floor(cursorRect.left) - 1,
floor(cursorRect.top) - 1,
ceil(cursorRect.right) + cursorWidthPx + 1,
ceil(cursorRect.bottom) + 1
)
val width = width
val height = height
this.assertPixels(
IntSize(width, height)
) { position ->
if (checkRect.contains(position.toOffset())) {
// cursor
cursorColor
} else if (skipRect.contains(position.toOffset())) {
// skip some pixels around cursor
null
} else {
// text field background
textFieldBgColor
}
}
}
}