1 /*
<lambda>null2 * Copyright 2021 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.annotation.VisibleForTesting
20 import androidx.collection.LongObjectMap
21 import androidx.collection.emptyLongObjectMap
22 import androidx.collection.mutableLongIntMapOf
23 import androidx.collection.mutableLongObjectMapOf
24 import androidx.compose.foundation.ComposeFoundationFlags
25 import androidx.compose.foundation.ExperimentalFoundationApi
26 import androidx.compose.foundation.focusable
27 import androidx.compose.foundation.gestures.awaitAllPointersUpWithSlopDetection
28 import androidx.compose.foundation.gestures.awaitEachGesture
29 import androidx.compose.foundation.gestures.awaitPrimaryFirstDown
30 import androidx.compose.foundation.internal.checkPreconditionNotNull
31 import androidx.compose.foundation.internal.requirePrecondition
32 import androidx.compose.foundation.internal.requirePreconditionNotNull
33 import androidx.compose.foundation.text.Handle
34 import androidx.compose.foundation.text.TextDragObserver
35 import androidx.compose.foundation.text.input.internal.coerceIn
36 import androidx.compose.foundation.text.isPositionInsideSelection
37 import androidx.compose.foundation.text.selection.Selection.AnchorInfo
38 import androidx.compose.runtime.MutableState
39 import androidx.compose.runtime.State
40 import androidx.compose.runtime.getValue
41 import androidx.compose.runtime.mutableStateOf
42 import androidx.compose.runtime.setValue
43 import androidx.compose.ui.Modifier
44 import androidx.compose.ui.focus.FocusRequester
45 import androidx.compose.ui.focus.focusRequester
46 import androidx.compose.ui.focus.onFocusChanged
47 import androidx.compose.ui.geometry.Offset
48 import androidx.compose.ui.geometry.Rect
49 import androidx.compose.ui.geometry.isSpecified
50 import androidx.compose.ui.geometry.isUnspecified
51 import androidx.compose.ui.hapticfeedback.HapticFeedback
52 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
53 import androidx.compose.ui.input.key.KeyEvent
54 import androidx.compose.ui.input.key.onKeyEvent
55 import androidx.compose.ui.input.pointer.PointerEventPass
56 import androidx.compose.ui.input.pointer.pointerInput
57 import androidx.compose.ui.layout.LayoutCoordinates
58 import androidx.compose.ui.layout.boundsInWindow
59 import androidx.compose.ui.layout.onGloballyPositioned
60 import androidx.compose.ui.layout.positionInRoot
61 import androidx.compose.ui.layout.positionInWindow
62 import androidx.compose.ui.platform.TextToolbar
63 import androidx.compose.ui.platform.TextToolbarStatus
64 import androidx.compose.ui.text.AnnotatedString
65 import androidx.compose.ui.text.buildAnnotatedString
66 import androidx.compose.ui.unit.IntSize
67 import androidx.compose.ui.util.fastAll
68 import androidx.compose.ui.util.fastAny
69 import androidx.compose.ui.util.fastFold
70 import androidx.compose.ui.util.fastForEach
71 import androidx.compose.ui.util.fastForEachIndexed
72 import androidx.compose.ui.util.fastMapNotNull
73 import kotlin.math.absoluteValue
74
75 /** A bridge class between user interaction to the text composables for text selection. */
76 internal class SelectionManager(private val selectionRegistrar: SelectionRegistrarImpl) {
77
78 private val _selection: MutableState<Selection?> = mutableStateOf(null)
79
80 /** The current selection. */
81 var selection: Selection?
82 get() = _selection.value
83 set(value) {
84 _selection.value = value
85 if (value != null) {
86 updateHandleOffsets()
87 }
88 }
89
90 /** Is touch mode active */
91 private val _isInTouchMode = mutableStateOf(true)
92 var isInTouchMode: Boolean
93 get() = _isInTouchMode.value
94 set(value) {
95 if (_isInTouchMode.value != value) {
96 _isInTouchMode.value = value
97 updateSelectionToolbar()
98 }
99 }
100
101 /**
102 * The manager will invoke this every time it comes to the conclusion that the selection should
103 * change. The expectation is that this callback will end up causing `setSelection` to get
104 * called. This is what makes this a "controlled component".
105 */
106 var onSelectionChange: (Selection?) -> Unit = { selection = it }
107 set(newOnSelectionChange) {
108 // Wrap the given lambda with one that sets the selection immediately.
109 // The onSelectionChange loop requires a composition to happen for the selection
110 // to be updated, so we want to shorten that loop for gesture use cases where
111 // multiple selection changing events can be acted on within a single composition
112 // loop. Previous selection is used as part of that loop so keeping it up to date
113 // is important.
114 field = { newSelection ->
115 selection = newSelection
116 newOnSelectionChange(newSelection)
117 }
118 }
119
120 /** [HapticFeedback] handle to perform haptic feedback. */
121 var hapticFeedBack: HapticFeedback? = null
122
123 /** A handler to perform a Copy action */
124 var onCopyHandler: ((AnnotatedString) -> Unit)? = null
125
126 /** [TextToolbar] to show floating toolbar(post-M) or primary toolbar(pre-M). */
127 var textToolbar: TextToolbar? = null
128
129 /** Focus requester used to request focus when selection becomes active. */
130 var focusRequester: FocusRequester = FocusRequester()
131
132 /** Return true if the corresponding SelectionContainer has a child that is focused. */
133 var hasFocus: Boolean by mutableStateOf(false)
134
135 /** Return true if dragging gesture is currently in process. */
136 private val isDraggingInProgress
137 get() = draggingHandle != null
138
139 /** Modifier for selection container. */
140 val modifier
141 get() =
142 Modifier.onClearSelectionRequested { onRelease() }
143 .onGloballyPositioned { containerLayoutCoordinates = it }
144 .focusRequester(focusRequester)
145 .onFocusChanged { focusState ->
146 if (!focusState.hasFocus && hasFocus) {
147 onRelease()
148 }
149 this.hasFocus = focusState.hasFocus
150 }
151 .focusable()
152 .updateSelectionTouchMode { isInTouchMode = it }
153 .onKeyEvent {
154 if (isCopyKeyEvent(it)) {
155 copy()
156 true
157 } else {
158 false
159 }
160 }
161 .then(if (shouldShowMagnifier) Modifier.selectionMagnifier(this) else Modifier)
162 .addContextMenuComponents()
163
164 private var previousPosition: Offset? = null
165
166 /** Layout Coordinates of the selection container. */
167 var containerLayoutCoordinates: LayoutCoordinates? = null
168 set(value) {
169 field = value
170 if (hasFocus && selection != null) {
171 val positionInWindow = value?.positionInWindow()
172 if (previousPosition != positionInWindow) {
173 previousPosition = positionInWindow
174 updateHandleOffsets()
175 updateSelectionToolbar()
176 }
177 }
178 }
179
180 /**
181 * The beginning position of the drag gesture. Every time a new drag gesture starts, it wil be
182 * recalculated.
183 */
184 internal var dragBeginPosition by mutableStateOf(Offset.Zero)
185 private set
186
187 /**
188 * The total distance being dragged of the drag gesture. Every time a new drag gesture starts,
189 * it will be zeroed out.
190 */
191 internal var dragTotalDistance by mutableStateOf(Offset.Zero)
192 private set
193
194 /**
195 * The calculated position of the start handle in the [SelectionContainer] coordinates. It is
196 * null when handle shouldn't be displayed. It is a [State] so reading it during the composition
197 * will cause recomposition every time the position has been changed.
198 */
199 var startHandlePosition: Offset? by mutableStateOf(null)
200 private set
201
202 /**
203 * The calculated position of the end handle in the [SelectionContainer] coordinates. It is null
204 * when handle shouldn't be displayed. It is a [State] so reading it during the composition will
205 * cause recomposition every time the position has been changed.
206 */
207 var endHandlePosition: Offset? by mutableStateOf(null)
208 private set
209
210 /**
211 * The handle that is currently being dragged, or null when no handle is being dragged. To get
212 * the position of the last drag event, use [currentDragPosition].
213 */
214 var draggingHandle: Handle? by mutableStateOf(null)
215 private set
216
217 /** Line height on start handle position */
218 val startHandleLineHeight: Float
219 get() {
220 val selection = this.selection ?: return 0f
221 val selectable = selection.start.let(::getAnchorSelectable) ?: return 0f
222 return selectable.getLineHeight(selection.start.offset)
223 }
224
225 /** Line height on end handle position */
226 val endHandleLineHeight: Float
227 get() {
228 val selection = this.selection ?: return 0f
229 val selectable = selection.end.let(::getAnchorSelectable) ?: return 0f
230 return selectable.getLineHeight(selection.end.offset)
231 }
232
233 /**
234 * When a handle is being dragged (i.e. [draggingHandle] is non-null), this is the last position
235 * of the actual drag event. It is not clamped to handle positions. Null when not being dragged.
236 */
237 var currentDragPosition: Offset? by mutableStateOf(null)
238 private set
239
240 private val shouldShowMagnifier
241 get() = isDraggingInProgress && isInTouchMode && !isTriviallyCollapsedSelection()
242
243 @VisibleForTesting internal var previousSelectionLayout: SelectionLayout? = null
244
245 init {
246 selectionRegistrar.onPositionChangeCallback = { selectableId ->
247 if (selectableId in selectionRegistrar.subselections) {
248 updateHandleOffsets()
249 updateSelectionToolbar()
250 }
251 }
252
253 selectionRegistrar.onSelectionUpdateStartCallback =
254 { isInTouchMode, layoutCoordinates, rawPosition, selectionMode ->
255 val textRect =
256 with(layoutCoordinates.size) { Rect(0f, 0f, width.toFloat(), height.toFloat()) }
257
258 val position =
259 if (textRect.containsInclusive(rawPosition)) {
260 rawPosition
261 } else {
262 rawPosition.coerceIn(textRect)
263 }
264
265 val positionInContainer = convertToContainerCoordinates(layoutCoordinates, position)
266
267 if (positionInContainer.isSpecified) {
268 this.isInTouchMode = isInTouchMode
269 startSelection(
270 position = positionInContainer,
271 isStartHandle = false,
272 adjustment = selectionMode
273 )
274
275 focusRequester.requestFocus()
276 showToolbar = false
277 }
278 }
279
280 selectionRegistrar.onSelectionUpdateSelectAll = { isInTouchMode, selectableId ->
281 val (newSelection, newSubselection) =
282 selectAllInSelectable(
283 selectableId = selectableId,
284 previousSelection = selection,
285 )
286 if (newSelection != selection) {
287 selectionRegistrar.subselections = newSubselection
288 onSelectionChange(newSelection)
289 }
290
291 this.isInTouchMode = isInTouchMode
292 focusRequester.requestFocus()
293 showToolbar = false
294 }
295
296 selectionRegistrar.onSelectionUpdateCallback =
297 {
298 isInTouchMode,
299 layoutCoordinates,
300 newPosition,
301 previousPosition,
302 isStartHandle,
303 selectionMode ->
304 val newPositionInContainer =
305 convertToContainerCoordinates(layoutCoordinates, newPosition)
306 val previousPositionInContainer =
307 convertToContainerCoordinates(layoutCoordinates, previousPosition)
308
309 this.isInTouchMode = isInTouchMode
310 updateSelection(
311 newPosition = newPositionInContainer,
312 previousPosition = previousPositionInContainer,
313 isStartHandle = isStartHandle,
314 adjustment = selectionMode
315 )
316 }
317
318 selectionRegistrar.onSelectionUpdateEndCallback = {
319 showToolbar = true
320 // This property is set by updateSelection while dragging, so we need to clear it after
321 // the original selection drag.
322 draggingHandle = null
323 currentDragPosition = null
324 }
325
326 // This function is meant to handle changes in the selectable content,
327 // such as the text changing.
328 selectionRegistrar.onSelectableChangeCallback = { selectableKey ->
329 if (selectableKey in selectionRegistrar.subselections) {
330 // Clear the selection range of each Selectable.
331 onRelease()
332 selection = null
333 }
334 }
335
336 selectionRegistrar.afterSelectableUnsubscribe = { selectableId ->
337 if (selectableId == selection?.start?.selectableId) {
338 // The selectable that contains a selection handle just unsubscribed.
339 // Hide the associated selection handle
340 startHandlePosition = null
341 }
342 if (selectableId == selection?.end?.selectableId) {
343 endHandlePosition = null
344 }
345
346 if (selectableId in selectionRegistrar.subselections) {
347 // Unsubscribing the selectable may make the selection empty, which would hide it.
348 updateSelectionToolbar()
349 }
350 }
351 }
352
353 /**
354 * Returns the [Selectable] responsible for managing the given [AnchorInfo], or null if the
355 * anchor is not from a currently-registered [Selectable].
356 */
357 internal fun getAnchorSelectable(anchor: AnchorInfo): Selectable? {
358 return selectionRegistrar.selectableMap[anchor.selectableId]
359 }
360
361 private fun updateHandleOffsets() {
362 val selection = selection
363 val containerCoordinates = containerLayoutCoordinates
364 val startSelectable = selection?.start?.let(::getAnchorSelectable)
365 val endSelectable = selection?.end?.let(::getAnchorSelectable)
366 val startLayoutCoordinates = startSelectable?.getLayoutCoordinates()
367 val endLayoutCoordinates = endSelectable?.getLayoutCoordinates()
368
369 if (
370 selection == null ||
371 containerCoordinates == null ||
372 !containerCoordinates.isAttached ||
373 (startLayoutCoordinates == null && endLayoutCoordinates == null)
374 ) {
375 this.startHandlePosition = null
376 this.endHandlePosition = null
377 return
378 }
379
380 val visibleBounds = containerCoordinates.visibleBounds()
381 this.startHandlePosition =
382 startLayoutCoordinates?.let { handleCoordinates ->
383 // Set the new handle position only if the handle is in visible bounds or
384 // the handle is still dragging. If handle goes out of visible bounds during drag,
385 // handle popup is also removed from composition, halting the drag gesture. This
386 // affects multiple text selection when selected text is configured with maxLines=1
387 // and overflow=clip.
388 val handlePosition =
389 startSelectable.getHandlePosition(selection, isStartHandle = true)
390 if (handlePosition.isUnspecified) return@let null
391 val position =
392 containerCoordinates.localPositionOf(handleCoordinates, handlePosition)
393 position.takeIf {
394 draggingHandle == Handle.SelectionStart || visibleBounds.containsInclusive(it)
395 }
396 }
397
398 this.endHandlePosition =
399 endLayoutCoordinates?.let { handleCoordinates ->
400 val handlePosition =
401 endSelectable.getHandlePosition(selection, isStartHandle = false)
402 if (handlePosition.isUnspecified) return@let null
403 val position =
404 containerCoordinates.localPositionOf(handleCoordinates, handlePosition)
405 position.takeIf {
406 draggingHandle == Handle.SelectionEnd || visibleBounds.containsInclusive(it)
407 }
408 }
409 }
410
411 /** Returns non-nullable [containerLayoutCoordinates]. */
412 internal fun requireContainerCoordinates(): LayoutCoordinates {
413 val coordinates = containerLayoutCoordinates
414 requirePreconditionNotNull(coordinates) { "null coordinates" }
415 requirePrecondition(coordinates.isAttached) { "unattached coordinates" }
416 return coordinates
417 }
418
419 internal fun selectAllInSelectable(
420 selectableId: Long,
421 previousSelection: Selection?
422 ): Pair<Selection?, LongObjectMap<Selection>> {
423 val subselections = mutableLongObjectMapOf<Selection>()
424 val newSelection =
425 selectionRegistrar.sort(requireContainerCoordinates()).fastFold(null) {
426 mergedSelection: Selection?,
427 selectable: Selectable ->
428 val selection =
429 if (selectable.selectableId == selectableId) selectable.getSelectAllSelection()
430 else null
431 selection?.let { subselections[selectable.selectableId] = it }
432 merge(mergedSelection, selection)
433 }
434 if (isInTouchMode && newSelection != previousSelection) {
435 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
436 }
437 return Pair(newSelection, subselections)
438 }
439
440 /** Returns whether the selection encompasses the entire container. */
441 internal fun isEntireContainerSelected(): Boolean {
442 val selectables = selectionRegistrar.sort(requireContainerCoordinates())
443
444 // If there are no selectables, then an empty selection spans the entire container.
445 if (selectables.isEmpty()) return true
446
447 // Since some text exists, we must make sure that every selectable is fully selected.
448 return selectables.fastAll {
449 val text = it.getText()
450 if (text.isEmpty()) return@fastAll true // empty text is inherently fully selected
451
452 // If a non-empty selectable isn't included in the sub-selections,
453 // then some text in the container is not selected.
454 val subSelection =
455 selectionRegistrar.subselections[it.selectableId] ?: return@fastAll false
456
457 val selectionStart = subSelection.start.offset
458 val selectionEnd = subSelection.end.offset
459
460 // The selection could be reversed,
461 // so just verify that the difference between the two offsets matches the text length
462 (selectionStart - selectionEnd).absoluteValue == text.length
463 }
464 }
465
466 /** Creates and sets a selection spanning the entire container. */
467 internal fun selectAll() {
468 val selectables = selectionRegistrar.sort(requireContainerCoordinates())
469 if (selectables.isEmpty()) return
470
471 var firstSubSelection: Selection? = null
472 var lastSubSelection: Selection? = null
473 val newSubSelections =
474 mutableLongObjectMapOf<Selection>().apply {
475 selectables.fastForEach { selectable ->
476 val subSelection = selectable.getSelectAllSelection() ?: return@fastForEach
477 if (firstSubSelection == null) firstSubSelection = subSelection
478 lastSubSelection = subSelection
479 put(selectable.selectableId, subSelection)
480 }
481 }
482
483 if (newSubSelections.isEmpty()) return
484
485 // first/last sub selections are implied to be non-null from here on out
486 val newSelection =
487 if (firstSubSelection === lastSubSelection) {
488 firstSubSelection
489 } else {
490 Selection(
491 start = firstSubSelection!!.start,
492 end = lastSubSelection!!.end,
493 handlesCrossed = false,
494 )
495 }
496
497 selectionRegistrar.subselections = newSubSelections
498 onSelectionChange(newSelection)
499 previousSelectionLayout = null
500 }
501
502 /**
503 * Returns whether the start and end anchors are equal.
504 *
505 * It is possible that this returns true, but the selection is still empty because it has
506 * multiple collapsed selections across multiple selectables. To test for that case, use
507 * [isNonEmptySelection].
508 */
509 internal fun isTriviallyCollapsedSelection(): Boolean {
510 val selection = selection ?: return true
511 return selection.start == selection.end
512 }
513
514 /**
515 * Returns whether the selection selects zero characters.
516 *
517 * It is possible that the selection anchors are different but still result in a zero-width
518 * selection. In this case, you may want to still show the selection anchors, but not allow for
519 * a user to try and copy zero characters. To test for whether the anchors are equal, use
520 * [isTriviallyCollapsedSelection].
521 */
522 internal fun isNonEmptySelection(): Boolean {
523 val selection = selection ?: return false
524 if (selection.start == selection.end) {
525 return false
526 }
527
528 if (selection.start.selectableId == selection.end.selectableId) {
529 // Selection is in the same selectable, but not the same anchors,
530 // so there must be some selected text.
531 return true
532 }
533
534 // All subselections associated with a selectable must be an empty selection.
535 return selectionRegistrar.sort(requireContainerCoordinates()).fastAny { selectable ->
536 selectionRegistrar.subselections[selectable.selectableId]?.run {
537 start.offset != end.offset
538 } ?: false
539 }
540 }
541
542 internal fun getSelectedText(): AnnotatedString? {
543 if (selection == null || selectionRegistrar.subselections.isEmpty()) {
544 return null
545 }
546
547 return buildAnnotatedString {
548 selectionRegistrar.sort(requireContainerCoordinates()).fastForEach { selectable ->
549 selectionRegistrar.subselections[selectable.selectableId]?.let { subSelection ->
550 val currentText = selectable.getText()
551 val currentSelectedText =
552 if (subSelection.handlesCrossed) {
553 currentText.subSequence(
554 subSelection.end.offset,
555 subSelection.start.offset
556 )
557 } else {
558 currentText.subSequence(
559 subSelection.start.offset,
560 subSelection.end.offset
561 )
562 }
563
564 append(currentSelectedText)
565 }
566 }
567 }
568 }
569
570 internal fun copy() {
571 getSelectedText()
572 ?.takeIf { it.isNotEmpty() }
573 ?.let { textToCopy -> onCopyHandler?.invoke(textToCopy) }
574 }
575
576 /**
577 * Whether toolbar should be shown right now. Examples: Show toolbar after user finishes
578 * selection. Hide it during selection. Hide it when no selection exists.
579 */
580 internal var showToolbar = false
581 internal set(value) {
582 field = value
583 updateSelectionToolbar()
584 }
585
586 private fun toolbarCopy() {
587 copy()
588 onRelease()
589 }
590
591 private fun updateSelectionToolbar() {
592 if (!hasFocus) {
593 return
594 }
595
596 val textToolbar = textToolbar ?: return
597 if (showToolbar && isInTouchMode) {
598 val rect = getContentRect() ?: return
599 textToolbar.showMenu(
600 rect = rect,
601 onCopyRequested = if (isNonEmptySelection()) ::toolbarCopy else null,
602 onSelectAllRequested = if (isEntireContainerSelected()) null else ::selectAll,
603 onAutofillRequested = null
604 )
605 } else if (textToolbar.status == TextToolbarStatus.Shown) {
606 textToolbar.hide()
607 }
608 }
609
610 /**
611 * Calculate selected region as [Rect]. The result is the smallest [Rect] that encapsulates the
612 * entire selection, coerced into visible bounds.
613 */
614 private fun getContentRect(): Rect? {
615 selection ?: return null
616 val containerCoordinates = containerLayoutCoordinates ?: return null
617 if (!containerCoordinates.isAttached) return null
618
619 val selectableSubSelections =
620 selectionRegistrar
621 .sort(requireContainerCoordinates())
622 .fastMapNotNull { selectable ->
623 selectionRegistrar.subselections[selectable.selectableId]?.let {
624 selectable to it
625 }
626 }
627 .firstAndLast()
628
629 if (selectableSubSelections.isEmpty()) return null
630 val selectedRegionRect =
631 getSelectedRegionRect(selectableSubSelections, containerCoordinates)
632
633 if (selectedRegionRect == invertedInfiniteRect) return null
634
635 val visibleRect = containerCoordinates.visibleBounds().intersect(selectedRegionRect)
636 // if the rectangles do not at least touch at the edges, we shouldn't show the toolbar
637 if (visibleRect.width < 0 || visibleRect.height < 0) return null
638
639 val rootRect = visibleRect.translate(containerCoordinates.positionInRoot())
640 return rootRect.copy(bottom = rootRect.bottom + HandleHeight.value * 4)
641 }
642
643 // This is for PressGestureDetector to cancel the selection.
644 fun onRelease() {
645 selectionRegistrar.subselections = emptyLongObjectMap()
646 showToolbar = false
647 if (selection != null) {
648 onSelectionChange(null)
649 if (isInTouchMode) {
650 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
651 }
652 }
653 }
654
655 fun handleDragObserver(isStartHandle: Boolean): TextDragObserver =
656 object : TextDragObserver {
657 override fun onDown(point: Offset) {
658 // if the handle position is null, then it is invisible, so ignore the gesture
659 (if (isStartHandle) startHandlePosition else endHandlePosition) ?: return
660
661 val selection = selection ?: return
662 val anchor = if (isStartHandle) selection.start else selection.end
663 val selectable = getAnchorSelectable(anchor) ?: return
664 // The LayoutCoordinates of the composable where the drag gesture should begin. This
665 // is used to convert the position of the beginning of the drag gesture from the
666 // composable coordinates to selection container coordinates.
667 val beginLayoutCoordinates = selectable.getLayoutCoordinates() ?: return
668
669 // The position of the character where the drag gesture should begin. This is in
670 // the composable coordinates.
671 val handlePosition = selectable.getHandlePosition(selection, isStartHandle)
672 if (handlePosition.isUnspecified) return
673 val beginCoordinates = getAdjustedCoordinates(handlePosition)
674
675 // Convert the position where drag gesture begins from composable coordinates to
676 // selection container coordinates.
677 currentDragPosition =
678 requireContainerCoordinates()
679 .localPositionOf(beginLayoutCoordinates, beginCoordinates)
680 draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
681 showToolbar = false
682 }
683
684 override fun onStart(startPoint: Offset) {
685 draggingHandle ?: return
686
687 val selection = selection!!
688 val anchor = if (isStartHandle) selection.start else selection.end
689 val selectable =
690 checkPreconditionNotNull(
691 selectionRegistrar.selectableMap[anchor.selectableId]
692 ) {
693 "SelectionRegistrar should contain the current selection's selectableIds"
694 }
695
696 // The LayoutCoordinates of the composable where the drag gesture should begin. This
697 // is used to convert the position of the beginning of the drag gesture from the
698 // composable coordinates to selection container coordinates.
699 val beginLayoutCoordinates =
700 checkPreconditionNotNull(selectable.getLayoutCoordinates()) {
701 "Current selectable should have layout coordinates."
702 }
703
704 // The position of the character where the drag gesture should begin. This is in
705 // the composable coordinates.
706 val handlePosition = selectable.getHandlePosition(selection, isStartHandle)
707 if (handlePosition.isUnspecified) return
708 val beginCoordinates = getAdjustedCoordinates(handlePosition)
709
710 // Convert the position where drag gesture begins from composable coordinates to
711 // selection container coordinates.
712 dragBeginPosition =
713 requireContainerCoordinates()
714 .localPositionOf(beginLayoutCoordinates, beginCoordinates)
715
716 // Zero out the total distance that being dragged.
717 dragTotalDistance = Offset.Zero
718 }
719
720 override fun onDrag(delta: Offset) {
721 draggingHandle ?: return
722
723 dragTotalDistance += delta
724 val endPosition = dragBeginPosition + dragTotalDistance
725 val consumed =
726 updateSelection(
727 newPosition = endPosition,
728 previousPosition = dragBeginPosition,
729 isStartHandle = isStartHandle,
730 adjustment = SelectionAdjustment.CharacterWithWordAccelerate
731 )
732 if (consumed) {
733 dragBeginPosition = endPosition
734 dragTotalDistance = Offset.Zero
735 }
736 }
737
738 private fun done() {
739 showToolbar = true
740 draggingHandle = null
741 currentDragPosition = null
742 }
743
744 override fun onUp() = done()
745
746 override fun onStop() = done()
747
748 override fun onCancel() = done()
749 }
750
751 @OptIn(ExperimentalFoundationApi::class)
752 private fun Modifier.addContextMenuComponents(): Modifier =
753 if (ComposeFoundationFlags.isNewContextMenuEnabled)
754 addSelectionContainerTextContextMenuComponents(this@SelectionManager)
755 else this
756
757 /** Clear the selection on up event that isn't a drag-end. */
758 private fun Modifier.onClearSelectionRequested(block: () -> Unit): Modifier =
759 pointerInput(Unit) {
760 awaitEachGesture {
761 // Wait for primary pointer to be down. It's required to explicitly filter
762 // secondary mouse button to make context menu work correctly.
763 val primaryFirstDown = awaitPrimaryFirstDown(requireUnconsumed = false)
764
765 // Wait for all pointers to be up, and if we're not dragging, clear the selection.
766 // Do it in the initial phase so that when this happens while dragging, we check
767 // isDraggingInProgress before the drag-end event clears it.
768 val pointerSlopReached =
769 awaitAllPointersUpWithSlopDetection(primaryFirstDown, PointerEventPass.Initial)
770 if (!pointerSlopReached && !isDraggingInProgress) {
771 block()
772 }
773 }
774 }
775
776 private fun convertToContainerCoordinates(
777 layoutCoordinates: LayoutCoordinates,
778 offset: Offset
779 ): Offset {
780 val coordinates = containerLayoutCoordinates
781 if (coordinates == null || !coordinates.isAttached) return Offset.Unspecified
782 return requireContainerCoordinates().localPositionOf(layoutCoordinates, offset)
783 }
784
785 /**
786 * Cancel the previous selection and start a new selection at the given [position]. It's used
787 * for long-press, double-click, triple-click and so on to start selection.
788 *
789 * @param position initial position of the selection. Both start and end handle is considered at
790 * this position.
791 * @param isStartHandle whether it's considered as the start handle moving. This parameter will
792 * influence the [SelectionAdjustment]'s behavior. For example,
793 * [SelectionAdjustment.Character] only adjust the moving handle.
794 * @param adjustment the selection adjustment.
795 */
796 private fun startSelection(
797 position: Offset,
798 isStartHandle: Boolean,
799 adjustment: SelectionAdjustment
800 ) {
801 previousSelectionLayout = null
802 updateSelection(
803 position = position,
804 previousHandlePosition = Offset.Unspecified,
805 isStartHandle = isStartHandle,
806 adjustment = adjustment
807 )
808 }
809
810 /**
811 * Updates the selection after one of the selection handle moved.
812 *
813 * @param newPosition the new position of the moving selection handle.
814 * @param previousPosition the previous position of the moving selection handle.
815 * @param isStartHandle whether the moving selection handle is the start handle.
816 * @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
817 * produce the final selection range.
818 * @return a boolean representing whether the movement is consumed.
819 * @see SelectionAdjustment
820 */
821 internal fun updateSelection(
822 newPosition: Offset?,
823 previousPosition: Offset,
824 isStartHandle: Boolean,
825 adjustment: SelectionAdjustment,
826 ): Boolean {
827 if (newPosition == null) return false
828 return updateSelection(
829 position = newPosition,
830 previousHandlePosition = previousPosition,
831 isStartHandle = isStartHandle,
832 adjustment = adjustment
833 )
834 }
835
836 /**
837 * Updates the selection after one of the selection handle moved.
838 *
839 * To make sure that [SelectionAdjustment] works correctly, it's expected that only one
840 * selection handle is updated each time. The only exception is that when a new selection is
841 * started. In this case, [previousHandlePosition] is always null.
842 *
843 * @param position the position of the current gesture.
844 * @param previousHandlePosition the position of the moving handle before the update.
845 * @param isStartHandle whether the moving selection handle is the start handle.
846 * @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
847 * produce the final selection range.
848 * @return a boolean representing whether the movement is consumed. It's useful for the case
849 * where a selection handle is updating consecutively. When the return value is true, it's
850 * expected that the caller will update the [startHandlePosition] to be the given
851 * [endHandlePosition] in following calls.
852 * @see SelectionAdjustment
853 */
854 internal fun updateSelection(
855 position: Offset,
856 previousHandlePosition: Offset,
857 isStartHandle: Boolean,
858 adjustment: SelectionAdjustment,
859 ): Boolean {
860 draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
861 currentDragPosition = position
862
863 val selectionLayout =
864 getSelectionLayout(position, previousHandlePosition, isStartHandle) ?: return false
865 if (!selectionLayout.shouldRecomputeSelection(previousSelectionLayout)) {
866 return false
867 }
868
869 val newSelection = adjustment.adjust(selectionLayout)
870 if (newSelection != selection) {
871 selectionChanged(selectionLayout, newSelection)
872 }
873 previousSelectionLayout = selectionLayout
874 return true
875 }
876
877 private fun getSelectionLayout(
878 position: Offset,
879 previousHandlePosition: Offset,
880 isStartHandle: Boolean,
881 ): SelectionLayout? {
882 val containerCoordinates = requireContainerCoordinates()
883 val sortedSelectables = selectionRegistrar.sort(containerCoordinates)
884
885 val idToIndexMap = mutableLongIntMapOf()
886 sortedSelectables.fastForEachIndexed { index, selectable ->
887 idToIndexMap[selectable.selectableId] = index
888 }
889
890 val selectableIdOrderingComparator = compareBy<Long> { idToIndexMap[it] }
891
892 // if previous handle is null, then treat this as a new selection.
893 val previousSelection = if (previousHandlePosition.isUnspecified) null else selection
894 val builder =
895 SelectionLayoutBuilder(
896 currentPosition = position,
897 previousHandlePosition = previousHandlePosition,
898 containerCoordinates = containerCoordinates,
899 isStartHandle = isStartHandle,
900 previousSelection = previousSelection,
901 selectableIdOrderingComparator = selectableIdOrderingComparator,
902 )
903
904 sortedSelectables.fastForEach { it.appendSelectableInfoToBuilder(builder) }
905
906 return builder.build()
907 }
908
909 private fun selectionChanged(selectionLayout: SelectionLayout, newSelection: Selection) {
910 if (shouldPerformHaptics()) {
911 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
912 }
913 selectionRegistrar.subselections = selectionLayout.createSubSelections(newSelection)
914 onSelectionChange(newSelection)
915 }
916
917 @VisibleForTesting
918 internal fun shouldPerformHaptics(): Boolean =
919 isInTouchMode && selectionRegistrar.selectables.fastAny { it.getText().isNotEmpty() }
920
921 /**
922 * Implements the macOS select-word-on-right-click behavior.
923 *
924 * If the current selection does not already include [position], select the word at [position].
925 */
926 fun selectWordAtPositionIfNotAlreadySelected(position: Offset) {
927 val containerCoordinates = containerLayoutCoordinates ?: return
928 if (!containerCoordinates.isAttached) return
929
930 val isClickedPositionInsideSelection =
931 selectionRegistrar.selectables.fastAny { selectable ->
932 val selection =
933 selectionRegistrar.subselections[selectable.selectableId]
934 ?: return@fastAny false
935 val selectableLayoutCoords =
936 selectable.getLayoutCoordinates() ?: return@fastAny false
937 val positionInSelectable =
938 selectableLayoutCoords.localPositionOf(containerCoordinates, position)
939 val textLayoutResult = selectable.textLayoutResult() ?: return@fastAny false
940 textLayoutResult.isPositionInsideSelection(
941 position = positionInSelectable,
942 selectionRange = selection.toTextRange()
943 )
944 }
945 if (!isClickedPositionInsideSelection) {
946 startSelection(
947 position = position,
948 isStartHandle = true,
949 adjustment = SelectionAdjustment.Word
950 )
951 }
952 }
953 }
954
mergenull955 internal fun merge(lhs: Selection?, rhs: Selection?): Selection? {
956 return lhs?.merge(rhs) ?: rhs
957 }
958
isCopyKeyEventnull959 internal expect fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean
960
961 internal expect fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier
962
963 internal expect fun Modifier.addSelectionContainerTextContextMenuComponents(
964 selectionManager: SelectionManager
965 ): Modifier
966
967 private val invertedInfiniteRect =
968 Rect(
969 left = Float.POSITIVE_INFINITY,
970 top = Float.POSITIVE_INFINITY,
971 right = Float.NEGATIVE_INFINITY,
972 bottom = Float.NEGATIVE_INFINITY
973 )
974
975 private fun <T> List<T>.firstAndLast(): List<T> =
976 when (size) {
977 0,
978 1 -> this
979 else -> listOf(first(), last())
980 }
981
982 /**
983 * Get the selected region rect in the given [containerCoordinates]. This will compute the smallest
984 * rect that contains every first/last character bounding box of each selectable. If for any reason
985 * there are no bounding boxes, then the [invertedInfiniteRect] is returned.
986 */
987 @VisibleForTesting
getSelectedRegionRectnull988 internal fun getSelectedRegionRect(
989 selectableSubSelectionPairs: List<Pair<Selectable, Selection>>,
990 containerCoordinates: LayoutCoordinates,
991 ): Rect {
992 if (selectableSubSelectionPairs.isEmpty()) return invertedInfiniteRect
993 var (containerLeft, containerTop, containerRight, containerBottom) = invertedInfiniteRect
994 selectableSubSelectionPairs.fastForEach { (selectable, subSelection) ->
995 val startOffset = subSelection.start.offset
996 val endOffset = subSelection.end.offset
997 if (startOffset == endOffset) return@fastForEach
998 val localCoordinates = selectable.getLayoutCoordinates() ?: return@fastForEach
999
1000 val minOffset = minOf(startOffset, endOffset)
1001 val maxOffset = maxOf(startOffset, endOffset)
1002 val offsets =
1003 if (minOffset == maxOffset - 1) {
1004 intArrayOf(minOffset)
1005 } else {
1006 intArrayOf(minOffset, maxOffset - 1)
1007 }
1008 var (left, top, right, bottom) = invertedInfiniteRect
1009 for (i in offsets) {
1010 val rect = selectable.getBoundingBox(i)
1011 left = minOf(left, rect.left)
1012 top = minOf(top, rect.top)
1013 right = maxOf(right, rect.right)
1014 bottom = maxOf(bottom, rect.bottom)
1015 }
1016
1017 val localTopLeft = Offset(left, top)
1018 val localBottomRight = Offset(right, bottom)
1019
1020 val containerTopLeft = containerCoordinates.localPositionOf(localCoordinates, localTopLeft)
1021 val containerBottomRight =
1022 containerCoordinates.localPositionOf(localCoordinates, localBottomRight)
1023
1024 containerLeft = minOf(containerLeft, containerTopLeft.x)
1025 containerTop = minOf(containerTop, containerTopLeft.y)
1026 containerRight = maxOf(containerRight, containerBottomRight.x)
1027 containerBottom = maxOf(containerBottom, containerBottomRight.y)
1028 }
1029 return Rect(containerLeft, containerTop, containerRight, containerBottom)
1030 }
1031
calculateSelectionMagnifierCenterAndroidnull1032 internal fun calculateSelectionMagnifierCenterAndroid(
1033 manager: SelectionManager,
1034 magnifierSize: IntSize
1035 ): Offset {
1036 val selection = manager.selection ?: return Offset.Unspecified
1037 return when (manager.draggingHandle) {
1038 null -> return Offset.Unspecified
1039 Handle.SelectionStart -> getMagnifierCenter(manager, magnifierSize, selection.start)
1040 Handle.SelectionEnd -> getMagnifierCenter(manager, magnifierSize, selection.end)
1041 Handle.Cursor -> error("SelectionContainer does not support cursor")
1042 }
1043 }
1044
getMagnifierCenternull1045 private fun getMagnifierCenter(
1046 manager: SelectionManager,
1047 magnifierSize: IntSize,
1048 anchor: AnchorInfo
1049 ): Offset {
1050 val selectable = manager.getAnchorSelectable(anchor) ?: return Offset.Unspecified
1051 val containerCoordinates = manager.containerLayoutCoordinates ?: return Offset.Unspecified
1052 val selectableCoordinates = selectable.getLayoutCoordinates() ?: return Offset.Unspecified
1053 val offset = anchor.offset
1054
1055 if (offset > selectable.getLastVisibleOffset()) return Offset.Unspecified
1056
1057 // The horizontal position doesn't snap to cursor positions but should directly track the
1058 // actual drag.
1059 val localDragPosition =
1060 selectableCoordinates.localPositionOf(containerCoordinates, manager.currentDragPosition!!)
1061 val dragX = localDragPosition.x
1062
1063 // But it is constrained by the horizontal bounds of the current line.
1064 val lineRange = selectable.getRangeOfLineContaining(offset)
1065 val textConstrainedX =
1066 if (lineRange.collapsed) {
1067 // A collapsed range implies the text is empty.
1068 // line left and right are equal for this offset, so use either
1069 selectable.getLineLeft(offset)
1070 } else {
1071 val lineStartX = selectable.getLineLeft(lineRange.start)
1072 val lineEndX = selectable.getLineRight(lineRange.end - 1)
1073 // in RTL/BiDi, lineStartX may be larger than lineEndX
1074 val minX = minOf(lineStartX, lineEndX)
1075 val maxX = maxOf(lineStartX, lineEndX)
1076 dragX.coerceIn(minX, maxX)
1077 }
1078
1079 // selectable couldn't determine horizontals
1080 if (textConstrainedX == -1f) return Offset.Unspecified
1081
1082 // Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
1083 // magnifier actually is). See
1084 // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
1085 // Also check whether magnifierSize is calculated. A platform magnifier instance is not
1086 // created until it's requested for the first time. So the size will only be calculated after we
1087 // return a specified offset from this function.
1088 // It is very unlikely that this behavior would cause a flicker since magnifier immediately
1089 // shows up where the pointer is being dragged. The pointer needs to drag further than the half
1090 // of magnifier's width to hide by the following logic.
1091 if (
1092 magnifierSize != IntSize.Zero &&
1093 (dragX - textConstrainedX).absoluteValue > magnifierSize.width / 2
1094 ) {
1095 return Offset.Unspecified
1096 }
1097
1098 val lineCenterY = selectable.getCenterYForOffset(offset)
1099
1100 // selectable couldn't determine the line center
1101 if (lineCenterY == -1f) return Offset.Unspecified
1102
1103 return containerCoordinates.localPositionOf(
1104 sourceCoordinates = selectableCoordinates,
1105 relativeToSource = Offset(textConstrainedX, lineCenterY)
1106 )
1107 }
1108
1109 /** Returns the boundary of the visible area in this [LayoutCoordinates]. */
visibleBoundsnull1110 internal fun LayoutCoordinates.visibleBounds(): Rect {
1111 // globalBounds is the global boundaries of this LayoutCoordinates after it's clipped by
1112 // parents. We can think it as the global visible bounds of this Layout. Here globalBounds
1113 // is convert to local, which is the boundary of the visible area within the LayoutCoordinates.
1114 val boundsInWindow = boundsInWindow()
1115 return Rect(windowToLocal(boundsInWindow.topLeft), windowToLocal(boundsInWindow.bottomRight))
1116 }
1117
containsInclusivenull1118 internal fun Rect.containsInclusive(offset: Offset): Boolean =
1119 offset.x in left..right && offset.y in top..bottom
1120