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