/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("DEPRECATION")
package androidx.compose.foundation.text.selection
import androidx.compose.foundation.fastFold
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.text.Handle
import androidx.compose.foundation.text.TextDragObserver
import androidx.compose.foundation.text.selection.Selection.AnchorInfo
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.IntSize
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.coroutineScope
/**
* A bridge class between user interaction to the text composables for text selection.
*/
internal class SelectionManager(private val selectionRegistrar: SelectionRegistrarImpl) {
private val _selection: MutableState<Selection?> = mutableStateOf(null)
/**
* The current selection.
*/
var selection: Selection?
get() = _selection.value
set(value) {
_selection.value = value
if (value != null) {
updateHandleOffsets()
}
}
/**
* Is touch mode active
*/
var touchMode: Boolean = true
/**
* The manager will invoke this every time it comes to the conclusion that the selection should
* change. The expectation is that this callback will end up causing `setSelection` to get
* called. This is what makes this a "controlled component".
*/
var onSelectionChange: (Selection?) -> Unit = {}
/**
* [HapticFeedback] handle to perform haptic feedback.
*/
var hapticFeedBack: HapticFeedback? = null
/**
* [ClipboardManager] to perform clipboard features.
*/
var clipboardManager: ClipboardManager? = null
/**
* [TextToolbar] to show floating toolbar(post-M) or primary toolbar(pre-M).
*/
var textToolbar: TextToolbar? = null
/**
* Focus requester used to request focus when selection becomes active.
*/
var focusRequester: FocusRequester = FocusRequester()
/**
* Return true if the corresponding SelectionContainer is focused.
*/
var hasFocus: Boolean by mutableStateOf(false)
/**
* Modifier for selection container.
*/
val modifier
get() = Modifier
.onClearSelectionRequested { onRelease() }
.onGloballyPositioned { containerLayoutCoordinates = it }
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
if (!focusState.isFocused && hasFocus) {
onRelease()
}
hasFocus = focusState.isFocused
}
.focusable()
.onKeyEvent {
if (isCopyKeyEvent(it)) {
copy()
true
} else {
false
}
}
.then(if (shouldShowMagnifier) Modifier.selectionMagnifier(this) else Modifier)
private var previousPosition: Offset? = null
/**
* Layout Coordinates of the selection container.
*/
var containerLayoutCoordinates: LayoutCoordinates? = null
set(value) {
field = value
if (hasFocus && selection != null) {
val positionInWindow = value?.positionInWindow()
if (previousPosition != positionInWindow) {
previousPosition = positionInWindow
updateHandleOffsets()
updateSelectionToolbarPosition()
}
}
}
/**
* The beginning position of the drag gesture. Every time a new drag gesture starts, it wil be
* recalculated.
*/
internal var dragBeginPosition by mutableStateOf(Offset.Zero)
private set
/**
* The total distance being dragged of the drag gesture. Every time a new drag gesture starts,
* it will be zeroed out.
*/
internal var dragTotalDistance by mutableStateOf(Offset.Zero)
private set
/**
* The calculated position of the start handle in the [SelectionContainer] coordinates. It
* is null when handle shouldn't be displayed.
* It is a [State] so reading it during the composition will cause recomposition every time
* the position has been changed.
*/
var startHandlePosition: Offset? by mutableStateOf(null)
private set
/**
* The calculated position of the end handle in the [SelectionContainer] coordinates. It
* is null when handle shouldn't be displayed.
* It is a [State] so reading it during the composition will cause recomposition every time
* the position has been changed.
*/
var endHandlePosition: Offset? by mutableStateOf(null)
private set
/**
* The handle that is currently being dragged, or null when no handle is being dragged. To get
* the position of the last drag event, use [currentDragPosition].
*/
var draggingHandle: Handle? by mutableStateOf(null)
private set
/**
* When a handle is being dragged (i.e. [draggingHandle] is non-null), this is the last position
* of the actual drag event. It is not clamped to handle positions. Null when not being dragged.
*/
var currentDragPosition: Offset? by mutableStateOf(null)
private set
private val shouldShowMagnifier get() = draggingHandle != null
init {
selectionRegistrar.onPositionChangeCallback = { selectableId ->
if (
selectableId == selection?.start?.selectableId ||
selectableId == selection?.end?.selectableId
) {
updateHandleOffsets()
updateSelectionToolbarPosition()
}
}
selectionRegistrar.onSelectionUpdateStartCallback =
{ layoutCoordinates, position, selectionMode ->
val positionInContainer = convertToContainerCoordinates(
layoutCoordinates,
position
)
if (positionInContainer != null) {
startSelection(
position = positionInContainer,
isStartHandle = false,
adjustment = selectionMode
)
focusRequester.requestFocus()
hideSelectionToolbar()
}
}
selectionRegistrar.onSelectionUpdateSelectAll =
{ selectableId ->
val (newSelection, newSubselection) = selectAll(
selectableId = selectableId,
previousSelection = selection,
)
if (newSelection != selection) {
selectionRegistrar.subselections = newSubselection
onSelectionChange(newSelection)
}
focusRequester.requestFocus()
hideSelectionToolbar()
}
selectionRegistrar.onSelectionUpdateCallback =
{ layoutCoordinates, newPosition, previousPosition, isStartHandle, selectionMode ->
val newPositionInContainer =
convertToContainerCoordinates(layoutCoordinates, newPosition)
val previousPositionInContainer =
convertToContainerCoordinates(layoutCoordinates, previousPosition)
updateSelection(
newPosition = newPositionInContainer,
previousPosition = previousPositionInContainer,
isStartHandle = isStartHandle,
adjustment = selectionMode
)
}
selectionRegistrar.onSelectionUpdateEndCallback = {
showSelectionToolbar()
// This property is set by updateSelection while dragging, so we need to clear it after
// the original selection drag.
draggingHandle = null
currentDragPosition = null
}
selectionRegistrar.onSelectableChangeCallback = { selectableKey ->
if (selectableKey in selectionRegistrar.subselections) {
// clear the selection range of each Selectable.
onRelease()
selection = null
}
}
selectionRegistrar.afterSelectableUnsubscribe = { selectableKey ->
if (
selectableKey == selection?.start?.selectableId ||
selectableKey == selection?.end?.selectableId
) {
// The selectable that contains a selection handle just unsubscribed.
// Hide selection handles for now
startHandlePosition = null
endHandlePosition = null
}
}
}
/**
* Returns the [Selectable] responsible for managing the given [AnchorInfo], or null if the
* anchor is not from a currently-registered [Selectable].
*/
internal fun getAnchorSelectable(anchor: AnchorInfo): Selectable? {
return selectionRegistrar.selectableMap[anchor.selectableId]
}
private fun updateHandleOffsets() {
val selection = selection
val containerCoordinates = containerLayoutCoordinates
val startSelectable = selection?.start?.let(::getAnchorSelectable)
val endSelectable = selection?.end?.let(::getAnchorSelectable)
val startLayoutCoordinates = startSelectable?.getLayoutCoordinates()
val endLayoutCoordinates = endSelectable?.getLayoutCoordinates()
if (
selection == null ||
containerCoordinates == null ||
!containerCoordinates.isAttached ||
startLayoutCoordinates == null ||
endLayoutCoordinates == null
) {
this.startHandlePosition = null
this.endHandlePosition = null
return
}
val startHandlePosition = containerCoordinates.localPositionOf(
startLayoutCoordinates,
startSelectable.getHandlePosition(
selection = selection,
isStartHandle = true
)
)
val endHandlePosition = containerCoordinates.localPositionOf(
endLayoutCoordinates,
endSelectable.getHandlePosition(
selection = selection,
isStartHandle = false
)
)
val visibleBounds = containerCoordinates.visibleBounds()
this.startHandlePosition =
if (visibleBounds.containsInclusive(startHandlePosition)) startHandlePosition else null
this.endHandlePosition =
if (visibleBounds.containsInclusive(endHandlePosition)) endHandlePosition else null
}
/**
* Returns non-nullable [containerLayoutCoordinates].
*/
internal fun requireContainerCoordinates(): LayoutCoordinates {
val coordinates = containerLayoutCoordinates
require(coordinates != null)
require(coordinates.isAttached)
return coordinates
}
internal fun selectAll(
selectableId: Long,
previousSelection: Selection?
): Pair<Selection?, Map<Long, Selection>> {
val subselections = mutableMapOf<Long, Selection>()
val newSelection = selectionRegistrar.sort(requireContainerCoordinates())
.fastFold(null) { mergedSelection: Selection?, selectable: Selectable ->
val selection = if (selectable.selectableId == selectableId)
selectable.getSelectAllSelection() else null
selection?.let { subselections[selectable.selectableId] = it }
merge(mergedSelection, selection)
}
if (newSelection != previousSelection) {
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
return Pair(newSelection, subselections)
}
internal fun getSelectedText(): AnnotatedString? {
val selectables = selectionRegistrar.sort(requireContainerCoordinates())
var selectedText: AnnotatedString? = null
selection?.let {
for (i in selectables.indices) {
val selectable = selectables[i]
// Continue if the current selectable is before the selection starts.
if (selectable.selectableId != it.start.selectableId &&
selectable.selectableId != it.end.selectableId &&
selectedText == null
) continue
val currentSelectedText = getCurrentSelectedText(
selectable = selectable,
selection = it
)
selectedText = selectedText?.plus(currentSelectedText) ?: currentSelectedText
// Break if the current selectable is the last selected selectable.
if (selectable.selectableId == it.end.selectableId && !it.handlesCrossed ||
selectable.selectableId == it.start.selectableId && it.handlesCrossed
) break
}
}
return selectedText
}
internal fun copy() {
val selectedText = getSelectedText()
selectedText?.let { clipboardManager?.setText(it) }
}
/**
* This function get the selected region as a Rectangle region, and pass it to [TextToolbar]
* to make the FloatingToolbar show up in the proper place. In addition, this function passes
* the copy method as a callback when "copy" is clicked.
*/
internal fun showSelectionToolbar() {
if (hasFocus) {
selection?.let {
textToolbar?.showMenu(
getContentRect(),
onCopyRequested = {
copy()
onRelease()
}
)
}
}
}
internal fun hideSelectionToolbar() {
if (hasFocus && textToolbar?.status == TextToolbarStatus.Shown) {
textToolbar?.hide()
}
}
private fun updateSelectionToolbarPosition() {
if (hasFocus && textToolbar?.status == TextToolbarStatus.Shown) {
showSelectionToolbar()
}
}
/**
* Calculate selected region as [Rect]. The top is the top of the first selected
* line, and the bottom is the bottom of the last selected line. The left is the leftmost
* handle's horizontal coordinates, and the right is the rightmost handle's coordinates.
*/
private fun getContentRect(): Rect {
val selection = selection ?: return Rect.Zero
val startSelectable = getAnchorSelectable(selection.start)
val endSelectable = getAnchorSelectable(selection.end)
val startLayoutCoordinates = startSelectable?.getLayoutCoordinates() ?: return Rect.Zero
val endLayoutCoordinates = endSelectable?.getLayoutCoordinates() ?: return Rect.Zero
val localLayoutCoordinates = containerLayoutCoordinates
if (localLayoutCoordinates != null && localLayoutCoordinates.isAttached) {
var startOffset = localLayoutCoordinates.localPositionOf(
startLayoutCoordinates,
startSelectable.getHandlePosition(
selection = selection,
isStartHandle = true
)
)
var endOffset = localLayoutCoordinates.localPositionOf(
endLayoutCoordinates,
endSelectable.getHandlePosition(
selection = selection,
isStartHandle = false
)
)
startOffset = localLayoutCoordinates.localToRoot(startOffset)
endOffset = localLayoutCoordinates.localToRoot(endOffset)
val left = min(startOffset.x, endOffset.x)
val right = max(startOffset.x, endOffset.x)
var startTop = localLayoutCoordinates.localPositionOf(
startLayoutCoordinates,
Offset(
0f,
startSelectable.getBoundingBox(selection.start.offset).top
)
)
var endTop = localLayoutCoordinates.localPositionOf(
endLayoutCoordinates,
Offset(
0.0f,
endSelectable.getBoundingBox(selection.end.offset).top
)
)
startTop = localLayoutCoordinates.localToRoot(startTop)
endTop = localLayoutCoordinates.localToRoot(endTop)
val top = min(startTop.y, endTop.y)
val bottom = max(startOffset.y, endOffset.y) + (HandleHeight.value * 4.0).toFloat()
return Rect(
left,
top,
right,
bottom
)
}
return Rect.Zero
}
// This is for PressGestureDetector to cancel the selection.
fun onRelease() {
selectionRegistrar.subselections = emptyMap()
hideSelectionToolbar()
if (selection != null) {
onSelectionChange(null)
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
fun handleDragObserver(isStartHandle: Boolean): TextDragObserver = object : TextDragObserver {
override fun onDown(point: Offset) {
val selection = selection ?: return
val anchor = if (isStartHandle) selection.start else selection.end
val selectable = getAnchorSelectable(anchor) ?: return
// The LayoutCoordinates of the composable where the drag gesture should begin. This
// is used to convert the position of the beginning of the drag gesture from the
// composable coordinates to selection container coordinates.
val beginLayoutCoordinates = selectable.getLayoutCoordinates() ?: return
// The position of the character where the drag gesture should begin. This is in
// the composable coordinates.
val beginCoordinates = getAdjustedCoordinates(
selectable.getHandlePosition(
selection = selection, isStartHandle = isStartHandle
)
)
// Convert the position where drag gesture begins from composable coordinates to
// selection container coordinates.
currentDragPosition = requireContainerCoordinates().localPositionOf(
beginLayoutCoordinates,
beginCoordinates
)
draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
}
override fun onUp() {
draggingHandle = null
currentDragPosition = null
}
override fun onStart(startPoint: Offset) {
hideSelectionToolbar()
val selection = selection!!
val startSelectable =
selectionRegistrar.selectableMap[selection.start.selectableId]
val endSelectable =
selectionRegistrar.selectableMap[selection.end.selectableId]
// The LayoutCoordinates of the composable where the drag gesture should begin. This
// is used to convert the position of the beginning of the drag gesture from the
// composable coordinates to selection container coordinates.
val beginLayoutCoordinates = if (isStartHandle) {
startSelectable?.getLayoutCoordinates()!!
} else {
endSelectable?.getLayoutCoordinates()!!
}
// The position of the character where the drag gesture should begin. This is in
// the composable coordinates.
val beginCoordinates = getAdjustedCoordinates(
if (isStartHandle) {
startSelectable!!.getHandlePosition(
selection = selection, isStartHandle = true
)
} else {
endSelectable!!.getHandlePosition(
selection = selection, isStartHandle = false
)
}
)
// Convert the position where drag gesture begins from composable coordinates to
// selection container coordinates.
dragBeginPosition = requireContainerCoordinates().localPositionOf(
beginLayoutCoordinates,
beginCoordinates
)
// Zero out the total distance that being dragged.
dragTotalDistance = Offset.Zero
}
override fun onDrag(delta: Offset) {
dragTotalDistance += delta
val endPosition = dragBeginPosition + dragTotalDistance
val consumed = updateSelection(
newPosition = endPosition,
previousPosition = dragBeginPosition,
isStartHandle = isStartHandle,
adjustment = SelectionAdjustment.CharacterWithWordAccelerate
)
if (consumed) {
dragBeginPosition = endPosition
dragTotalDistance = Offset.Zero
}
}
override fun onStop() {
showSelectionToolbar()
draggingHandle = null
currentDragPosition = null
}
override fun onCancel() {
showSelectionToolbar()
draggingHandle = null
currentDragPosition = null
}
}
/**
* Detect tap without consuming the up event.
*/
private suspend fun PointerInputScope.detectNonConsumingTap(onTap: (Offset) -> Unit) {
forEachGesture {
coroutineScope {
awaitPointerEventScope {
waitForUpOrCancellation()?.let {
onTap(it.position)
}
}
}
}
}
private fun Modifier.onClearSelectionRequested(block: () -> Unit): Modifier {
return if (hasFocus) pointerInput(Unit) { detectNonConsumingTap { block() } } else this
}
private fun convertToContainerCoordinates(
layoutCoordinates: LayoutCoordinates,
offset: Offset
): Offset? {
val coordinates = containerLayoutCoordinates
if (coordinates == null || !coordinates.isAttached) return null
return requireContainerCoordinates().localPositionOf(layoutCoordinates, offset)
}
/**
* Cancel the previous selection and start a new selection at the given [position].
* It's used for long-press, double-click, triple-click and so on to start selection.
*
* @param position initial position of the selection. Both start and end handle is considered
* at this position.
* @param isStartHandle whether it's considered as the start handle moving. This parameter
* will influence the [SelectionAdjustment]'s behavior. For example,
* [SelectionAdjustment.Character] only adjust the moving handle.
* @param adjustment the selection adjustment.
*/
private fun startSelection(
position: Offset,
isStartHandle: Boolean,
adjustment: SelectionAdjustment
) {
updateSelection(
startHandlePosition = position,
endHandlePosition = position,
previousHandlePosition = null,
isStartHandle = isStartHandle,
adjustment = adjustment
)
}
/**
* Updates the selection after one of the selection handle moved.
*
* @param newPosition the new position of the moving selection handle.
* @param previousPosition the previous position of the moving selection handle.
* @param isStartHandle whether the moving selection handle is the start handle.
* @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
* produce the final selection range.
*
* @return a boolean representing whether the movement is consumed.
*
* @see SelectionAdjustment
*/
internal fun updateSelection(
newPosition: Offset?,
previousPosition: Offset?,
isStartHandle: Boolean,
adjustment: SelectionAdjustment,
): Boolean {
if (newPosition == null) return false
val otherHandlePosition = selection?.let { selection ->
val otherSelectableId = if (isStartHandle) {
selection.end.selectableId
} else {
selection.start.selectableId
}
val otherSelectable =
selectionRegistrar.selectableMap[otherSelectableId] ?: return@let null
convertToContainerCoordinates(
otherSelectable.getLayoutCoordinates()!!,
getAdjustedCoordinates(
otherSelectable.getHandlePosition(selection, !isStartHandle)
)
)
} ?: return false
val startHandlePosition = if (isStartHandle) newPosition else otherHandlePosition
val endHandlePosition = if (isStartHandle) otherHandlePosition else newPosition
return updateSelection(
startHandlePosition = startHandlePosition,
endHandlePosition = endHandlePosition,
previousHandlePosition = previousPosition,
isStartHandle = isStartHandle,
adjustment = adjustment
)
}
/**
* Updates the selection after one of the selection handle moved.
*
* To make sure that [SelectionAdjustment] works correctly, it's expected that only one
* selection handle is updated each time. The only exception is that when a new selection is
* started. In this case, [previousHandlePosition] is always null.
*
* @param startHandlePosition the position of the start selection handle.
* @param endHandlePosition the position of the end selection handle.
* @param previousHandlePosition the position of the moving handle before the update.
* @param isStartHandle whether the moving selection handle is the start handle.
* @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
* produce the final selection range.
*
* @return a boolean representing whether the movement is consumed. It's useful for the case
* where a selection handle is updating consecutively. When the return value is true, it's
* expected that the caller will update the [startHandlePosition] to be the given
* [endHandlePosition] in following calls.
*
* @see SelectionAdjustment
*/
internal fun updateSelection(
startHandlePosition: Offset,
endHandlePosition: Offset,
previousHandlePosition: Offset?,
isStartHandle: Boolean,
adjustment: SelectionAdjustment,
): Boolean {
draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
currentDragPosition = if (isStartHandle) startHandlePosition else endHandlePosition
val newSubselections = mutableMapOf<Long, Selection>()
var moveConsumed = false
val newSelection = selectionRegistrar.sort(requireContainerCoordinates())
.fastFold(null) { mergedSelection: Selection?, selectable: Selectable ->
val previousSubselection =
selectionRegistrar.subselections[selectable.selectableId]
val (selection, consumed) = selectable.updateSelection(
startHandlePosition = startHandlePosition,
endHandlePosition = endHandlePosition,
previousHandlePosition = previousHandlePosition,
isStartHandle = isStartHandle,
containerLayoutCoordinates = requireContainerCoordinates(),
adjustment = adjustment,
previousSelection = previousSubselection,
)
moveConsumed = moveConsumed || consumed
selection?.let { newSubselections[selectable.selectableId] = it }
merge(mergedSelection, selection)
}
if (newSelection != selection) {
hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
selectionRegistrar.subselections = newSubselections
onSelectionChange(newSelection)
}
return moveConsumed
}
fun contextMenuOpenAdjustment(position: Offset) {
val isEmptySelection = selection?.toTextRange()?.collapsed ?: true
// TODO(b/209483184) the logic should be more complex here, it should check that current
// selection doesn't include click position
if (isEmptySelection) {
startSelection(
position = position,
isStartHandle = true,
adjustment = SelectionAdjustment.Word
)
}
}
}
internal fun merge(lhs: Selection?, rhs: Selection?): Selection? {
return lhs?.merge(rhs) ?: rhs
}
internal expect fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean
internal expect fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier
internal fun calculateSelectionMagnifierCenterAndroid(
manager: SelectionManager,
magnifierSize: IntSize
): Offset {
fun getMagnifierCenter(anchor: AnchorInfo, isStartHandle: Boolean): Offset {
val selectable = manager.getAnchorSelectable(anchor) ?: return Offset.Unspecified
val containerCoordinates = manager.containerLayoutCoordinates ?: return Offset.Unspecified
val selectableCoordinates = selectable.getLayoutCoordinates() ?: return Offset.Unspecified
// The end offset is exclusive.
val offset = if (isStartHandle) anchor.offset else anchor.offset - 1
// The horizontal position doesn't snap to cursor positions but should directly track the
// actual drag.
val localDragPosition = selectableCoordinates.localPositionOf(
containerCoordinates,
manager.currentDragPosition!!
)
val dragX = localDragPosition.x
// But it is constrained by the horizontal bounds of the current line.
val centerX = selectable.getRangeOfLineContaining(offset).let { line ->
val lineMin = selectable.getBoundingBox(line.min)
// line.end is exclusive, but we want the bounding box of the actual last character in
// the line.
val lineMax = selectable.getBoundingBox((line.max - 1).coerceAtLeast(line.min))
val minX = minOf(lineMin.left, lineMax.left)
val maxX = maxOf(lineMin.right, lineMax.right)
dragX.coerceIn(minX, maxX)
}
// Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
// magnifier actually is). See
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
if ((dragX - centerX).absoluteValue > magnifierSize.width / 2) {
return Offset.Unspecified
}
// Let the selectable determine the vertical position of the magnifier, since it should be
// clamped to the center of text lines.
val anchorBounds = selectable.getBoundingBox(offset)
val centerY = anchorBounds.center.y
return containerCoordinates.localPositionOf(
sourceCoordinates = selectableCoordinates,
relativeToSource = Offset(centerX, centerY)
)
}
val selection = manager.selection ?: return Offset.Unspecified
return when (manager.draggingHandle) {
null -> return Offset.Unspecified
Handle.SelectionStart -> getMagnifierCenter(selection.start, isStartHandle = true)
Handle.SelectionEnd -> getMagnifierCenter(selection.end, isStartHandle = false)
Handle.Cursor -> error("SelectionContainer does not support cursor")
}
}
internal fun getCurrentSelectedText(
selectable: Selectable,
selection: Selection
): AnnotatedString {
val currentText = selectable.getText()
return if (
selectable.selectableId != selection.start.selectableId &&
selectable.selectableId != selection.end.selectableId
) {
// Select the full text content if the current selectable is between the
// start and the end selectables.
currentText
} else if (
selectable.selectableId == selection.start.selectableId &&
selectable.selectableId == selection.end.selectableId
) {
// Select partial text content if the current selectable is the start and
// the end selectable.
if (selection.handlesCrossed) {
currentText.subSequence(selection.end.offset, selection.start.offset)
} else {
currentText.subSequence(selection.start.offset, selection.end.offset)
}
} else if (selectable.selectableId == selection.start.selectableId) {
// Select partial text content if the current selectable is the start
// selectable.
if (selection.handlesCrossed) {
currentText.subSequence(0, selection.start.offset)
} else {
currentText.subSequence(selection.start.offset, currentText.length)
}
} else {
// Selectable partial text content if the current selectable is the end
// selectable.
if (selection.handlesCrossed) {
currentText.subSequence(selection.end.offset, currentText.length)
} else {
currentText.subSequence(0, selection.end.offset)
}
}
}
/** Returns the boundary of the visible area in this [LayoutCoordinates]. */
internal fun LayoutCoordinates.visibleBounds(): Rect {
// globalBounds is the global boundaries of this LayoutCoordinates after it's clipped by
// parents. We can think it as the global visible bounds of this Layout. Here globalBounds
// is convert to local, which is the boundary of the visible area within the LayoutCoordinates.
val boundsInWindow = boundsInWindow()
return Rect(
windowToLocal(boundsInWindow.topLeft),
windowToLocal(boundsInWindow.bottomRight)
)
}
internal fun Rect.containsInclusive(offset: Offset): Boolean =
offset.x in left..right && offset.y in top..bottom