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