1 /*
2 * Copyright 2020 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 @file:Suppress("DEPRECATION")
18
19 package androidx.compose.foundation.text.selection
20
21 import androidx.compose.foundation.clickable
22 import androidx.compose.foundation.internal.readText
23 import androidx.compose.foundation.internal.toClipEntry
24 import androidx.compose.foundation.layout.Box
25 import androidx.compose.foundation.layout.Column
26 import androidx.compose.foundation.layout.Row
27 import androidx.compose.foundation.layout.fillMaxWidth
28 import androidx.compose.foundation.layout.height
29 import androidx.compose.foundation.layout.size
30 import androidx.compose.foundation.layout.width
31 import androidx.compose.foundation.text.BasicText
32 import androidx.compose.foundation.text.Handle
33 import androidx.compose.foundation.text.test.assertThatIntRect
34 import androidx.compose.runtime.CompositionLocalProvider
35 import androidx.compose.runtime.rememberCoroutineScope
36 import androidx.compose.ui.Alignment
37 import androidx.compose.ui.Modifier
38 import androidx.compose.ui.geometry.Offset
39 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
40 import androidx.compose.ui.input.key.Key
41 import androidx.compose.ui.input.pointer.PointerEventPass
42 import androidx.compose.ui.input.pointer.changedToUp
43 import androidx.compose.ui.layout.LayoutCoordinates
44 import androidx.compose.ui.platform.Clipboard
45 import androidx.compose.ui.platform.ClipboardManager
46 import androidx.compose.ui.platform.LocalClipboard
47 import androidx.compose.ui.platform.LocalClipboardManager
48 import androidx.compose.ui.platform.LocalDensity
49 import androidx.compose.ui.platform.testTag
50 import androidx.compose.ui.semantics.SemanticsActions
51 import androidx.compose.ui.test.ExperimentalTestApi
52 import androidx.compose.ui.test.SemanticsNodeInteraction
53 import androidx.compose.ui.test.TouchInjectionScope
54 import androidx.compose.ui.test.click
55 import androidx.compose.ui.test.getUnclippedBoundsInRoot
56 import androidx.compose.ui.test.junit4.ComposeTestRule
57 import androidx.compose.ui.test.longClick
58 import androidx.compose.ui.test.onNodeWithTag
59 import androidx.compose.ui.test.onRoot
60 import androidx.compose.ui.test.performClick
61 import androidx.compose.ui.test.performKeyInput
62 import androidx.compose.ui.test.performSemanticsAction
63 import androidx.compose.ui.test.performTouchInput
64 import androidx.compose.ui.text.AnnotatedString
65 import androidx.compose.ui.text.TextLayoutResult
66 import androidx.compose.ui.text.TextStyle
67 import androidx.compose.ui.text.style.TextOverflow
68 import androidx.compose.ui.unit.Density
69 import androidx.compose.ui.unit.Dp
70 import androidx.compose.ui.unit.IntOffset
71 import androidx.compose.ui.unit.IntRect
72 import androidx.compose.ui.unit.dp
73 import androidx.compose.ui.unit.roundToIntRect
74 import androidx.compose.ui.unit.width
75 import androidx.test.ext.junit.runners.AndroidJUnit4
76 import androidx.test.filters.LargeTest
77 import androidx.test.filters.SdkSuppress
78 import com.google.common.truth.Truth.assertThat
79 import kotlin.math.sign
80 import kotlinx.coroutines.CoroutineStart
81 import kotlinx.coroutines.launch
82 import kotlinx.coroutines.test.runTest
83 import org.junit.Test
84 import org.junit.runner.RunWith
85 import org.mockito.kotlin.times
86 import org.mockito.kotlin.verify
87
88 @Suppress("DEPRECATION")
89 @LargeTest
90 @RunWith(AndroidJUnit4::class)
91 internal class SelectionContainerTest : AbstractSelectionContainerTest() {
92
93 @Test
94 @SdkSuppress(minSdkVersion = 27)
press_to_cancelnull95 fun press_to_cancel() {
96 // Setup. Long press to create a selection.
97 // A reasonable number.
98 createSelectionContainer()
99 val position = 50f
100 rule.onSelectionContainer().performTouchInput {
101 longClick(Offset(x = position, y = position))
102 }
103 rule.runOnIdle { assertThat(selection.value).isNotNull() }
104
105 // Act.
106 rule.onSelectionContainer().performTouchInput { click(Offset(x = position, y = position)) }
107
108 // Assert.
109 rule.runOnIdle {
110 assertThat(selection.value).isNull()
111 verify(hapticFeedback, times(2))
112 .performHapticFeedback(HapticFeedbackType.TextHandleMove)
113 }
114 }
115
116 @Test
117 @SdkSuppress(minSdkVersion = 27)
tapToCancelDoesNotBlockUpnull118 fun tapToCancelDoesNotBlockUp() {
119 // Setup. Long press to create a selection.
120 // A reasonable number.
121 createSelectionContainer()
122 val position = 50f
123 rule.onSelectionContainer().performTouchInput {
124 longClick(Offset(x = position, y = position))
125 }
126
127 log.entries.clear()
128
129 // Act.
130 rule.onSelectionContainer().performTouchInput { click(Offset(x = position, y = position)) }
131
132 // Assert.
133 rule.runOnIdle {
134 // We are interested in looking at the final up event.
135 assertThat(log.entries.last().pass).isEqualTo(PointerEventPass.Final)
136 assertThat(log.entries.last().changes).hasSize(1)
137 assertThat(log.entries.last().changes[0].changedToUp()).isTrue()
138 }
139 }
140
141 @Test
long_press_select_a_wordnull142 fun long_press_select_a_word() {
143 with(rule.density) {
144 // Setup.
145 // Long Press "m" in "Demo", and "Demo" should be selected.
146 createSelectionContainer()
147 val characterSize = fontSize.toPx()
148 val expectedLeftX = fontSize.toDp().times(textContent.indexOf('D'))
149 val expectedLeftY = fontSize.toDp()
150 val expectedRightX = fontSize.toDp().times(textContent.indexOf('o') + 1)
151 val expectedRightY = fontSize.toDp()
152
153 // Act.
154 rule.onSelectionContainer().performTouchInput {
155 longClick(Offset(textContent.indexOf('m') * characterSize, 0.5f * characterSize))
156 }
157
158 rule.mainClock.advanceTimeByFrame()
159 // Assert. Should select "Demo".
160 assertThat(selection.value!!.start.offset).isEqualTo(textContent.indexOf('D'))
161 assertThat(selection.value!!.end.offset).isEqualTo(textContent.indexOf('o') + 1)
162 verify(hapticFeedback, times(1))
163 .performHapticFeedback(HapticFeedbackType.TextHandleMove)
164
165 // Check the position of the anchors of the selection handles. We don't need to compare
166 // to the absolute position since the semantics report selection relative to the
167 // container composable, not the screen.
168 rule
169 .onNode(isSelectionHandle(Handle.SelectionStart))
170 .assertHandlePositionMatches(expectedLeftX, expectedLeftY)
171 rule
172 .onNode(isSelectionHandle(Handle.SelectionEnd))
173 .assertHandlePositionMatches(expectedRightX, expectedRightY)
174 }
175 }
176
177 @Test
long_press_select_a_word_rtl_layoutnull178 fun long_press_select_a_word_rtl_layout() {
179 with(rule.density) {
180 // Setup.
181 // Long Press "m" in "Demo", and "Demo" should be selected.
182 createSelectionContainer(isRtl = true)
183 val characterSize = fontSize.toPx()
184 val expectedLeftX = rule.rootWidth() - fontSize.toDp().times(textContent.length)
185 val expectedLeftY = fontSize.toDp()
186 val expectedRightX = rule.rootWidth() - fontSize.toDp().times(" Demo Text".length)
187 val expectedRightY = fontSize.toDp()
188
189 // Act.
190 rule.onSelectionContainer().performTouchInput {
191 longClick(
192 Offset(
193 rule.rootWidth().toSp().toPx() - ("xt Demo Text").length * characterSize,
194 0.5f * characterSize
195 )
196 )
197 }
198
199 rule.mainClock.advanceTimeByFrame()
200
201 // Assert. Should select "Demo".
202 assertThat(selection.value!!.start.offset).isEqualTo(textContent.indexOf('T'))
203 assertThat(selection.value!!.end.offset).isEqualTo(textContent.indexOf('t') + 1)
204 verify(hapticFeedback, times(1))
205 .performHapticFeedback(HapticFeedbackType.TextHandleMove)
206
207 // Check the position of the anchors of the selection handles. We don't need to compare
208 // to the absolute position since the semantics report selection relative to the
209 // container composable, not the screen.
210 rule
211 .onNode(isSelectionHandle(Handle.SelectionStart))
212 .assertHandlePositionMatches(expectedLeftX, expectedLeftY)
213 rule
214 .onNode(isSelectionHandle(Handle.SelectionEnd))
215 .assertHandlePositionMatches(expectedRightX, expectedRightY)
216 }
217 }
218
219 @Test
selectionContinues_toBelowTextnull220 fun selectionContinues_toBelowText() {
221 createSelectionContainer {
222 Column {
223 BasicText(
224 AnnotatedString(textContent),
225 Modifier.fillMaxWidth().testTag(tag1),
226 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize)
227 )
228 BasicText(
229 AnnotatedString(textContent),
230 Modifier.fillMaxWidth().testTag(tag2),
231 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize)
232 )
233 }
234 }
235
236 startSelection(tag1)
237 dragHandleTo(Handle.SelectionEnd, offset = characterBox(tag2, 3).bottomRight)
238
239 assertAnchorInfo(selection.value?.start, offset = 0, selectableId = 1)
240 assertAnchorInfo(selection.value?.end, offset = 4, selectableId = 2)
241 }
242
243 @Test
selectionContinues_toAboveTextnull244 fun selectionContinues_toAboveText() {
245 createSelectionContainer {
246 Column {
247 BasicText(
248 AnnotatedString(textContent),
249 Modifier.fillMaxWidth().testTag(tag1),
250 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize)
251 )
252 BasicText(
253 AnnotatedString(textContent),
254 Modifier.fillMaxWidth().testTag(tag2),
255 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize)
256 )
257 }
258 }
259
260 startSelection(tag2, offset = 6) // second word should be selected
261 dragHandleTo(Handle.SelectionStart, offset = characterBox(tag1, 5).bottomLeft)
262
263 assertAnchorInfo(selection.value?.start, offset = 5, selectableId = 1)
264 assertAnchorInfo(selection.value?.end, offset = 9, selectableId = 2)
265 }
266
267 @Test
selectionContinues_toNextText_skipsDisableSelectionnull268 fun selectionContinues_toNextText_skipsDisableSelection() {
269 createSelectionContainer {
270 Column {
271 BasicText(
272 AnnotatedString(textContent),
273 Modifier.fillMaxWidth().testTag(tag1),
274 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize)
275 )
276 DisableSelection {
277 BasicText(
278 AnnotatedString(textContent),
279 Modifier.fillMaxWidth().testTag(tag2),
280 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize)
281 )
282 }
283 }
284 }
285
286 startSelection(tag1)
287 dragHandleTo(Handle.SelectionEnd, offset = characterBox(tag2, 3).bottomRight)
288
289 assertAnchorInfo(selection.value?.start, offset = 0, selectableId = 1)
290 assertAnchorInfo(selection.value?.end, offset = textContent.length, selectableId = 1)
291 }
292
293 @Test
selectionHandle_remainsInComposition_whenTextIsOverflowed_clipped_softwrapDisablednull294 fun selectionHandle_remainsInComposition_whenTextIsOverflowed_clipped_softwrapDisabled() {
295 createSelectionContainer {
296 Column {
297 BasicText(
298 AnnotatedString("$textContent ".repeat(100)),
299 Modifier.fillMaxWidth().testTag(tag1),
300 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
301 softWrap = false
302 )
303 DisableSelection {
304 BasicText(
305 textContent,
306 Modifier.fillMaxWidth(),
307 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
308 softWrap = false
309 )
310 }
311 BasicText(
312 AnnotatedString("$textContent ".repeat(100)),
313 Modifier.fillMaxWidth().testTag(tag2),
314 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
315 softWrap = false
316 )
317 }
318 }
319
320 startSelection(tag1)
321 dragHandleTo(Handle.SelectionEnd, offset = characterBox(tag2, 3).bottomRight)
322
323 assertAnchorInfo(selection.value?.start, offset = 0, selectableId = 1)
324 assertAnchorInfo(selection.value?.end, offset = 4, selectableId = 2)
325 }
326
327 @Test
allTextIsSelected_whenTextIsOverflowed_clipped_maxLines1null328 fun allTextIsSelected_whenTextIsOverflowed_clipped_maxLines1() =
329 with(rule.density) {
330 val longText = "$textContent ".repeat(100)
331 createSelectionContainer {
332 Column {
333 BasicText(
334 AnnotatedString(longText),
335 Modifier.fillMaxWidth().testTag(tag1),
336 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
337 maxLines = 1
338 )
339 }
340 }
341
342 startSelection(tag1)
343 dragHandleTo(
344 handle = Handle.SelectionEnd,
345 offset = characterBox(tag1, 4).bottomRight + Offset(x = 0f, y = fontSize.toPx())
346 )
347
348 assertAnchorInfo(selection.value?.start, offset = 0, selectableId = 1)
349 assertAnchorInfo(selection.value?.end, offset = longText.length, selectableId = 1)
350 }
351
352 @Test
allTextIsSelected_whenTextIsOverflowed_ellipsized_maxLines1null353 fun allTextIsSelected_whenTextIsOverflowed_ellipsized_maxLines1() =
354 with(rule.density) {
355 val longText = "$textContent ".repeat(100)
356 createSelectionContainer {
357 Column {
358 BasicText(
359 AnnotatedString(longText),
360 Modifier.fillMaxWidth().testTag(tag1),
361 style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
362 maxLines = 1,
363 overflow = TextOverflow.Ellipsis
364 )
365 }
366 }
367
368 startSelection(tag1)
369 dragHandleTo(
370 handle = Handle.SelectionEnd,
371 offset = characterBox(tag1, 4).bottomRight + Offset(x = 0f, y = fontSize.toPx())
372 )
373
374 assertAnchorInfo(selection.value?.start, offset = 0, selectableId = 1)
375 assertAnchorInfo(selection.value?.end, offset = longText.length, selectableId = 1)
376 }
377
378 @Test
selectionIncludes_noHeightTextnull379 fun selectionIncludes_noHeightText() {
380 lateinit var clipboard: Clipboard
381 createSelectionContainer {
382 clipboard = LocalClipboard.current
383 rememberCoroutineScope().launch(start = CoroutineStart.UNDISPATCHED) {
384 clipboard.setClipEntry(
385 AnnotatedString("Clipboard content at start of test.").toClipEntry()
386 )
387 }
388 Column {
389 BasicText(
390 text = "Hello",
391 modifier = Modifier.fillMaxWidth().testTag(tag1),
392 )
393 BasicText(text = "THIS SHOULD NOT CAUSE CRASH", modifier = Modifier.height(0.dp))
394 BasicText(
395 text = "World",
396 modifier = Modifier.fillMaxWidth().testTag(tag2),
397 )
398 }
399 }
400
401 startSelection(tag1)
402 dragHandleTo(handle = Handle.SelectionEnd, offset = characterBox(tag2, 4).bottomRight)
403
404 assertAnchorInfo(selection.value?.start, offset = 0, selectableId = 1)
405 assertAnchorInfo(selection.value?.end, offset = 5, selectableId = 3)
406 }
407
408 @Test
selectionIncludes_noWidthTextnull409 fun selectionIncludes_noWidthText() {
410 lateinit var clipboard: Clipboard
411 createSelectionContainer {
412 clipboard = LocalClipboard.current
413 rememberCoroutineScope().launch(start = CoroutineStart.UNDISPATCHED) {
414 clipboard.setClipEntry(
415 AnnotatedString("Clipboard content at start of test.").toClipEntry()
416 )
417 }
418 Column {
419 BasicText(
420 text = "Hello",
421 modifier = Modifier.fillMaxWidth().testTag(tag1),
422 )
423 BasicText(text = "THIS SHOULD NOT CAUSE CRASH", modifier = Modifier.width(0.dp))
424 BasicText(
425 text = "World",
426 modifier = Modifier.fillMaxWidth().testTag(tag2),
427 )
428 }
429 }
430
431 startSelection(tag1)
432 dragHandleTo(handle = Handle.SelectionEnd, offset = characterBox(tag2, 4).bottomRight)
433
434 assertAnchorInfo(selection.value?.start, offset = 0, selectableId = 1)
435 assertAnchorInfo(selection.value?.end, offset = 5, selectableId = 3)
436 }
437
438 /**
439 * Regression test for b/372053402 - Modifier.weight not working on SelectionContainer.
440 *
441 * Lay out a selection container with a weight next to a fixed size box in a row. We expect the
442 * selection container to take up the remaining space in the row that the box does not use.
443 */
444 @Test
selectionContainer_layoutWeightAppliesnull445 fun selectionContainer_layoutWeightApplies() {
446 val density = Density(1f)
447 with(density) {
448 val rowSize = 100
449 val boxSize = 10
450 val expectedSelConWidth = rowSize - boxSize
451
452 val rowTag = "row"
453 val boxTag = "box"
454 val selConTag = "sel"
455 val textTag = "text"
456
457 rule.setContent {
458 CompositionLocalProvider(LocalDensity provides density) {
459 Row(Modifier.size(rowSize.toDp()).testTag(rowTag)) {
460 SelectionContainer(Modifier.weight(1f).testTag(selConTag)) {
461 TestText("text ".repeat(100).trim(), Modifier.testTag(textTag))
462 }
463 Box(Modifier.size(boxSize.toDp()).testTag(boxTag))
464 }
465 }
466 }
467
468 fun layoutCoordinatesForTag(tag: String): LayoutCoordinates =
469 rule.onNodeWithTag(tag).fetchSemanticsNode().layoutInfo.coordinates
470
471 val rowCoords = layoutCoordinatesForTag(rowTag)
472 fun boundsInRow(tag: String): IntRect =
473 rowCoords
474 .localBoundingBoxOf(layoutCoordinatesForTag(tag), clipBounds = false)
475 .roundToIntRect()
476
477 val rowBounds = IntRect(IntOffset.Zero, rowCoords.size)
478 val selConBounds = boundsInRow(selConTag)
479 val textBounds = boundsInRow(textTag)
480 val boxBounds = boundsInRow(boxTag)
481
482 assertThatIntRect(rowBounds)
483 .isEqualTo(top = 0, left = 0, right = rowSize, bottom = rowSize)
484 assertThatIntRect(selConBounds)
485 .isEqualTo(top = 0, left = 0, right = expectedSelConWidth, bottom = rowSize)
486 assertThatIntRect(textBounds)
487 .isEqualTo(top = 0, left = 0, right = expectedSelConWidth, bottom = rowSize)
488 assertThatIntRect(boxBounds)
489 .isEqualTo(top = 0, left = expectedSelConWidth, right = rowSize, bottom = boxSize)
490 }
491 }
492
493 @Test
494 @OptIn(ExperimentalTestApi::class)
<lambda>null495 fun selection_doesCopy_whenCopyKeyEventSent() = runTest {
496 lateinit var clipboardManager: ClipboardManager
497 lateinit var clipboard: Clipboard
498 createSelectionContainer {
499 clipboardManager = LocalClipboardManager.current
500 clipboard = LocalClipboard.current
501 clipboardManager.setText(AnnotatedString("Clipboard content at start of test."))
502 Column {
503 BasicText(
504 text = "ExpectedText",
505 modifier = Modifier.fillMaxWidth().testTag(tag1),
506 )
507 }
508 }
509
510 startSelection(tag1)
511
512 rule.onNodeWithTag(tag1).performKeyInput {
513 keyDown(Key.CtrlLeft)
514 keyDown(Key.C)
515 keyUp(Key.C)
516 keyUp(Key.CtrlLeft)
517 }
518
519 rule.runOnIdle { assertThat(clipboardManager.getText()?.text).isEqualTo("ExpectedText") }
520 assertThat(clipboard.getClipEntry()?.readText()).isEqualTo("ExpectedText")
521 }
522
523 @Test
buttonWithTextClickInsideSelectionContainernull524 fun buttonWithTextClickInsideSelectionContainer() {
525 var clickCounter = 0
526 createSelectionContainer {
527 Box(Modifier.clickable { clickCounter++ }) {
528 BasicText(
529 text = "Button",
530 modifier = Modifier.align(Alignment.Center).testTag(tag1),
531 )
532 }
533 }
534 rule.onNodeWithTag(tag1, useUnmergedTree = true).performClick()
535 rule.runOnIdle { assertThat(clickCounter).isEqualTo(1) }
536 }
537
538 @Test
buttonClickClearsSelectionnull539 fun buttonClickClearsSelection() =
540 with(rule.density) {
541 var clickCounter = 0
542 createSelectionContainer {
543 Column {
544 TestText(textContent)
545 TestButton(Modifier.size(50.dp).testTag(tag1), onClick = { clickCounter++ }) {
546 TestText("Button")
547 }
548 }
549 }
550 val characterSize = fontSize.toPx()
551
552 // Act. Long Press "m" in "Demo", and "Demo" should be selected.
553 rule.onSelectionContainer().performTouchInput {
554 longClick(Offset(textContent.indexOf('m') * characterSize, 0.5f * characterSize))
555 }
556 rule.onNodeWithTag(tag1, useUnmergedTree = true).performClick()
557
558 // Assert.
559 rule.runOnIdle { assertThat(selection.value).isNull() }
560 rule.runOnIdle { assertThat(clickCounter).isEqualTo(1) }
561 }
562
563 @Test
buttonClickInsideDisableSelectionClearsSelectionnull564 fun buttonClickInsideDisableSelectionClearsSelection() =
565 with(rule.density) {
566 var clickCounter = 0
567 createSelectionContainer {
568 Column {
569 TestText(textContent)
570 DisableSelection {
571 TestButton(
572 Modifier.size(50.dp).testTag(tag1),
573 onClick = { clickCounter++ }
574 ) {
575 TestText("Button")
576 }
577 }
578 }
579 }
580 val characterSize = fontSize.toPx()
581
582 // Act. Long Press "m" in "Demo", and "Demo" should be selected.
583 rule.onSelectionContainer().performTouchInput {
584 longClick(Offset(textContent.indexOf('m') * characterSize, 0.5f * characterSize))
585 }
586 rule.onNodeWithTag(tag1, useUnmergedTree = true).performClick()
587
588 // Assert.
589 rule.runOnIdle { assertThat(selection.value).isNull() }
590 rule.runOnIdle { assertThat(clickCounter).isEqualTo(1) }
591 }
592
startSelectionnull593 private fun startSelection(tag: String, offset: Int = 0) {
594 val textLayoutResult = rule.onNodeWithTag(tag).fetchTextLayoutResult()
595 val boundingBox = textLayoutResult.getBoundingBox(offset)
596 rule.onNodeWithTag(tag).performTouchInput { longClick(boundingBox.center) }
597 }
598
dragHandleTonull599 private fun dragHandleTo(handle: Handle, offset: Offset) {
600 val position =
601 rule
602 .onNode(isSelectionHandle(handle))
603 .fetchSemanticsNode()
604 .config[SelectionHandleInfoKey]
605 .position
606 // selection handles are anchored at the bottom of a line.
607
608 rule.onNode(isSelectionHandle(handle)).performTouchInput {
609 val delta = offset - position
610 down(center)
611 movePastSlopBy(delta)
612 up()
613 }
614 }
615
616 /**
617 * Moves the first pointer by [delta] past the touch slop threshold on each axis. If [delta] is
618 * 0 on either axis it will stay 0.
619 */
TouchInjectionScopenull620 private fun TouchInjectionScope.movePastSlopBy(delta: Offset) {
621 val slop =
622 Offset(
623 x = viewConfiguration.touchSlop * delta.x.sign,
624 y = viewConfiguration.touchSlop * delta.y.sign
625 )
626 moveBy(delta + slop)
627 }
628 }
629
rootWidthnull630 private fun ComposeTestRule.rootWidth(): Dp = onRoot().getUnclippedBoundsInRoot().width
631
632 /**
633 * Returns the text layout result caught by [SemanticsActions.GetTextLayoutResult] under this node.
634 * Throws an AssertionError if the node has not defined GetTextLayoutResult semantics action.
635 */
636 fun SemanticsNodeInteraction.fetchTextLayoutResult(): TextLayoutResult {
637 val textLayoutResults = mutableListOf<TextLayoutResult>()
638 performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(textLayoutResults) }
639 return textLayoutResults.first()
640 }
641