1 /*
<lambda>null2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.foundation.text.selection
18
19 import androidx.compose.foundation.clickable
20 import androidx.compose.foundation.layout.Box
21 import androidx.compose.foundation.layout.fillMaxSize
22 import androidx.compose.foundation.text.BasicText
23 import androidx.compose.foundation.text.TEST_FONT_FAMILY
24 import androidx.compose.runtime.Composable
25 import androidx.compose.runtime.CompositionLocalProvider
26 import androidx.compose.runtime.mutableStateOf
27 import androidx.compose.runtime.remember
28 import androidx.compose.ui.Alignment
29 import androidx.compose.ui.Modifier
30 import androidx.compose.ui.composed
31 import androidx.compose.ui.geometry.Rect
32 import androidx.compose.ui.hapticfeedback.HapticFeedback
33 import androidx.compose.ui.input.pointer.PointerEvent
34 import androidx.compose.ui.input.pointer.PointerEventPass
35 import androidx.compose.ui.input.pointer.PointerInputChange
36 import androidx.compose.ui.input.pointer.PointerInputFilter
37 import androidx.compose.ui.input.pointer.PointerInputModifier
38 import androidx.compose.ui.layout.Layout
39 import androidx.compose.ui.platform.LocalHapticFeedback
40 import androidx.compose.ui.platform.LocalLayoutDirection
41 import androidx.compose.ui.platform.testTag
42 import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
43 import androidx.compose.ui.test.hasAnyChild
44 import androidx.compose.ui.test.hasTestTag
45 import androidx.compose.ui.test.isRoot
46 import androidx.compose.ui.test.junit4.createComposeRule
47 import androidx.compose.ui.test.onNodeWithTag
48 import androidx.compose.ui.text.AnnotatedString
49 import androidx.compose.ui.text.TextStyle
50 import androidx.compose.ui.text.style.ResolvedTextDirection
51 import androidx.compose.ui.text.style.TextOverflow
52 import androidx.compose.ui.unit.IntSize
53 import androidx.compose.ui.unit.LayoutDirection
54 import androidx.compose.ui.unit.sp
55 import com.google.common.truth.Truth.assertThat
56 import kotlin.math.max
57 import org.junit.Rule
58 import org.mockito.kotlin.mock
59
60 internal abstract class AbstractSelectionContainerTest {
61 @get:Rule val rule = createComposeRule().also { it.mainClock.autoAdvance = false }
62
63 protected val textContent = "Text Demo Text"
64 protected val fontFamily = TEST_FONT_FAMILY
65 protected val selection = mutableStateOf<Selection?>(null)
66 protected val fontSize = 20.sp
67 protected val log = PointerInputChangeLog()
68
69 protected val tag1 = "tag1"
70 protected val tag2 = "tag2"
71
72 protected val hapticFeedback = mock<HapticFeedback>()
73
74 protected fun characterBox(tag: String, offset: Int): Rect {
75 val nodePosition = rule.onNodeWithTag(tag).fetchSemanticsNode().positionInRoot
76 val textLayoutResult = rule.onNodeWithTag(tag).fetchTextLayoutResult()
77 return textLayoutResult.getBoundingBox(offset).translate(nodePosition)
78 }
79
80 protected fun SemanticsNodeInteractionsProvider.onSelectionContainer() =
81 onNode(isRoot() and hasAnyChild(hasTestTag("selectionContainer")))
82
83 protected fun assertAnchorInfo(
84 anchorInfo: Selection.AnchorInfo?,
85 resolvedTextDirection: ResolvedTextDirection = ResolvedTextDirection.Ltr,
86 offset: Int = 0,
87 selectableId: Long = 0
88 ) {
89 assertThat(anchorInfo)
90 .isEqualTo(Selection.AnchorInfo(resolvedTextDirection, offset, selectableId))
91 }
92
93 protected fun createSelectionContainer(
94 isRtl: Boolean = false,
95 content: (@Composable () -> Unit)? = null
96 ) {
97 val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
98 rule.setContent {
99 CompositionLocalProvider(
100 LocalHapticFeedback provides hapticFeedback,
101 LocalLayoutDirection provides layoutDirection
102 ) {
103 TestParent(Modifier.testTag("selectionContainer").gestureSpy(log)) {
104 SelectionContainer(
105 selection = selection.value,
106 onSelectionChange = { selection.value = it }
107 ) {
108 content?.invoke() ?: TestText(textContent, Modifier.fillMaxSize())
109 }
110 }
111 }
112 }
113
114 rule.waitForIdle()
115 }
116
117 @Composable
118 protected fun TestText(text: String, modifier: Modifier = Modifier) {
119 BasicText(
120 text = AnnotatedString(text),
121 modifier = modifier,
122 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
123 softWrap = true,
124 overflow = TextOverflow.Clip,
125 maxLines = Int.MAX_VALUE,
126 inlineContent = mapOf(),
127 onTextLayout = {}
128 )
129 }
130
131 @Composable
132 protected fun TestButton(
133 modifier: Modifier = Modifier,
134 onClick: () -> Unit,
135 content: @Composable () -> Unit
136 ) {
137 Box(
138 modifier.clickable(onClick = onClick), // It marks this node as focusable
139 contentAlignment = Alignment.Center,
140 ) {
141 content()
142 }
143 }
144 }
145
146 @Composable
TestParentnull147 fun TestParent(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
148 Layout(modifier = modifier, content = content) { measurables, constraints ->
149 val placeables = measurables.map { measurable -> measurable.measure(constraints) }
150
151 val width = placeables.fold(0) { maxWidth, placeable -> max(maxWidth, (placeable.width)) }
152
153 val height = placeables.fold(0) { minWidth, placeable -> max(minWidth, (placeable.height)) }
154
155 layout(width, height) { placeables.forEach { placeable -> placeable.place(0, 0) } }
156 }
157 }
158
159 internal class PointerInputChangeLog : (PointerEvent, PointerEventPass) -> Unit {
160
161 val entries = mutableListOf<PointerInputChangeLogEntry>()
162
invokenull163 override fun invoke(p1: PointerEvent, p2: PointerEventPass) {
164 entries.add(PointerInputChangeLogEntry(p1.changes.map { it }, p2))
165 }
166 }
167
168 internal data class PointerInputChangeLogEntry(
169 val changes: List<PointerInputChange>,
170 val pass: PointerEventPass
171 )
172
gestureSpynull173 private fun Modifier.gestureSpy(
174 onPointerInput: (PointerEvent, PointerEventPass) -> Unit
175 ): Modifier = composed {
176 val spy = remember { GestureSpy() }
177 spy.onPointerInput = onPointerInput
178 spy
179 }
180
181 private class GestureSpy : PointerInputModifier {
182
183 lateinit var onPointerInput: (PointerEvent, PointerEventPass) -> Unit
184
185 override val pointerInputFilter =
186 object : PointerInputFilter() {
onPointerEventnull187 override fun onPointerEvent(
188 pointerEvent: PointerEvent,
189 pass: PointerEventPass,
190 bounds: IntSize
191 ) {
192 onPointerInput(pointerEvent, pass)
193 }
194
onCancelnull195 override fun onCancel() {
196 // Nothing to implement
197 }
198 }
199 }
200