[go: nahoru, domu]

blob: 465fc4640f00138f646a4057c61214a939ee4602 [file] [log] [blame]
Qingqing Deng6f56a912019-05-13 10:10:37 -07001/*
haoyuac341f02021-01-22 22:01:56 -08002 * Copyright 2021 The Android Open Source Project
Qingqing Deng6f56a912019-05-13 10:10:37 -07003 *
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
haoyuac341f02021-01-22 22:01:56 -080017package androidx.compose.foundation.text.selection
Qingqing Deng6f56a912019-05-13 10:10:37 -070018
Grant Toepferd681b5c2023-06-14 11:24:02 -070019import androidx.annotation.VisibleForTesting
Grant Toepfer39043c82024-04-22 11:33:23 -070020import androidx.collection.LongObjectMap
21import androidx.collection.emptyLongObjectMap
22import androidx.collection.mutableLongIntMapOf
23import androidx.collection.mutableLongObjectMapOf
haoyuc40d02752021-01-25 17:32:47 -080024import androidx.compose.foundation.focusable
George Mount32de9dd2022-10-05 14:51:06 -070025import androidx.compose.foundation.gestures.awaitEachGesture
Ralston Da Silvade62bc62021-06-02 17:46:44 -070026import androidx.compose.foundation.gestures.waitForUpOrCancellation
Zach Klippensteinadabe342021-11-11 16:38:13 -080027import androidx.compose.foundation.text.Handle
28import androidx.compose.foundation.text.TextDragObserver
Halil Ozercan325cadb2024-02-07 15:14:35 +000029import androidx.compose.foundation.text.input.internal.coerceIn
Zach Klippensteinadabe342021-11-11 16:38:13 -080030import androidx.compose.foundation.text.selection.Selection.AnchorInfo
31import androidx.compose.runtime.MutableState
Louis Pullen-Freilich1f10a592020-07-24 16:35:14 +010032import androidx.compose.runtime.State
33import androidx.compose.runtime.getValue
34import androidx.compose.runtime.mutableStateOf
35import androidx.compose.runtime.setValue
haoyuc40d02752021-01-25 17:32:47 -080036import androidx.compose.ui.Modifier
37import androidx.compose.ui.focus.FocusRequester
38import androidx.compose.ui.focus.focusRequester
haoyuc40d02752021-01-25 17:32:47 -080039import androidx.compose.ui.focus.onFocusChanged
Louis Pullen-Freilich534385a2020-08-14 14:10:12 +010040import androidx.compose.ui.geometry.Offset
41import androidx.compose.ui.geometry.Rect
Grant Toepferd681b5c2023-06-14 11:24:02 -070042import androidx.compose.ui.geometry.isSpecified
43import androidx.compose.ui.geometry.isUnspecified
Louis Pullen-Freilicha03fd6c2020-07-24 23:26:29 +010044import androidx.compose.ui.hapticfeedback.HapticFeedback
45import androidx.compose.ui.hapticfeedback.HapticFeedbackType
Andrey Rudenko8b4981e2021-01-29 15:31:59 +010046import androidx.compose.ui.input.key.KeyEvent
47import androidx.compose.ui.input.key.onKeyEvent
Ralston Da Silvade62bc62021-06-02 17:46:44 -070048import androidx.compose.ui.input.pointer.PointerInputScope
49import androidx.compose.ui.input.pointer.pointerInput
Louis Pullen-Freilich534385a2020-08-14 14:10:12 +010050import androidx.compose.ui.layout.LayoutCoordinates
George Mount77ca2a22020-12-11 17:46:19 +000051import androidx.compose.ui.layout.boundsInWindow
haoyuc40d02752021-01-25 17:32:47 -080052import androidx.compose.ui.layout.onGloballyPositioned
Grant Toepferef76a142023-11-07 11:56:49 -070053import androidx.compose.ui.layout.positionInRoot
haoyu7ad5ea32021-03-22 10:36:35 -070054import androidx.compose.ui.layout.positionInWindow
Louis Pullen-Freilich534385a2020-08-14 14:10:12 +010055import androidx.compose.ui.platform.ClipboardManager
Louis Pullen-Freilicha03fd6c2020-07-24 23:26:29 +010056import androidx.compose.ui.platform.TextToolbar
57import androidx.compose.ui.platform.TextToolbarStatus
Louis Pullen-Freilichab194752020-07-21 22:21:22 +010058import androidx.compose.ui.text.AnnotatedString
Grant Toepfer427dbdf2023-09-05 18:38:42 -070059import androidx.compose.ui.text.buildAnnotatedString
Zach Klippenstein9c32bdf2021-11-11 16:38:13 -080060import androidx.compose.ui.unit.IntSize
Grant Toepfer8a0c81d2024-04-19 10:26:47 -070061import androidx.compose.ui.util.fastAll
Grant Toepferd681b5c2023-06-14 11:24:02 -070062import androidx.compose.ui.util.fastAny
Louis Pullen-Freilich1ffdbfc2023-08-24 18:35:34 +010063import androidx.compose.ui.util.fastFold
Grant Toepferfefbd7a2023-03-03 15:02:55 -080064import androidx.compose.ui.util.fastForEach
Grant Toepferd681b5c2023-06-14 11:24:02 -070065import androidx.compose.ui.util.fastForEachIndexed
Grant Toepferef76a142023-11-07 11:56:49 -070066import androidx.compose.ui.util.fastMapNotNull
Zach Klippenstein9c32bdf2021-11-11 16:38:13 -080067import kotlin.math.absoluteValue
Qingqing Deng6f56a912019-05-13 10:10:37 -070068
Qingqing Deng35f97ea2019-09-18 19:24:37 -070069/**
Louis Pullen-Freilich5da28bd2019-10-15 17:05:07 +010070 * A bridge class between user interaction to the text composables for text selection.
Qingqing Deng35f97ea2019-09-18 19:24:37 -070071 */
Siyamed Sinir700df8452019-10-22 20:23:58 -070072internal class SelectionManager(private val selectionRegistrar: SelectionRegistrarImpl) {
Zach Klippensteinadabe342021-11-11 16:38:13 -080073
74 private val _selection: MutableState<Selection?> = mutableStateOf(null)
75
Qingqing Deng6f56a912019-05-13 10:10:37 -070076 /**
77 * The current selection.
78 */
Zach Klippensteinadabe342021-11-11 16:38:13 -080079 var selection: Selection?
80 get() = _selection.value
Andrey Kulikovb8c7e052020-04-15 19:20:09 +010081 set(value) {
Zach Klippensteinadabe342021-11-11 16:38:13 -080082 _selection.value = value
haoyu9085c882020-12-08 12:01:06 -080083 if (value != null) {
84 updateHandleOffsets()
85 }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +010086 }
Qingqing Deng6f56a912019-05-13 10:10:37 -070087
88 /**
Andrey Rudenko8b4981e2021-01-29 15:31:59 +010089 * Is touch mode active
90 */
Grant Toepferfefbd7a2023-03-03 15:02:55 -080091 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
Grant Toepfer427dbdf2023-09-05 18:38:42 -070097 updateSelectionToolbar()
Grant Toepferfefbd7a2023-03-03 15:02:55 -080098 }
99 }
Andrey Rudenko8b4981e2021-01-29 15:31:59 +0100100
101 /**
Qingqing Deng6f56a912019-05-13 10:10:37 -0700102 * 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 */
Grant Toepfer4e6fa502024-03-05 15:49:57 -0800106 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 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700119
120 /**
Qingqing Deng25b8f8d2020-01-17 16:36:19 -0800121 * [HapticFeedback] handle to perform haptic feedback.
122 */
123 var hapticFeedBack: HapticFeedback? = null
124
125 /**
Qingqing Dengf2d0a2d2020-03-12 14:19:50 -0700126 * [ClipboardManager] to perform clipboard features.
127 */
128 var clipboardManager: ClipboardManager? = null
129
130 /**
Qingqing Denga89450d2020-04-03 19:11:49 -0700131 * [TextToolbar] to show floating toolbar(post-M) or primary toolbar(pre-M).
132 */
133 var textToolbar: TextToolbar? = null
134
135 /**
haoyuc40d02752021-01-25 17:32:47 -0800136 * Focus requester used to request focus when selection becomes active.
137 */
138 var focusRequester: FocusRequester = FocusRequester()
139
140 /**
haoyu3c3fb452021-02-18 01:01:14 -0800141 * Return true if the corresponding SelectionContainer is focused.
haoyuc40d02752021-01-25 17:32:47 -0800142 */
haoyu3c3fb452021-02-18 01:01:14 -0800143 var hasFocus: Boolean by mutableStateOf(false)
haoyuc40d02752021-01-25 17:32:47 -0800144
145 /**
146 * Modifier for selection container.
147 */
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800148 val modifier
149 get() = Modifier
150 .onClearSelectionRequested { onRelease() }
151 .onGloballyPositioned { containerLayoutCoordinates = it }
152 .focusRequester(focusRequester)
153 .onFocusChanged { focusState ->
154 if (!focusState.isFocused && hasFocus) {
155 onRelease()
156 }
157 hasFocus = focusState.isFocused
haoyuc40d02752021-01-25 17:32:47 -0800158 }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800159 .focusable()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800160 .updateSelectionTouchMode { isInTouchMode = it }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800161 .onKeyEvent {
162 if (isCopyKeyEvent(it)) {
163 copy()
164 true
165 } else {
166 false
167 }
Andrey Rudenko8b4981e2021-01-29 15:31:59 +0100168 }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800169 .then(if (shouldShowMagnifier) Modifier.selectionMagnifier(this) else Modifier)
haoyuc40d02752021-01-25 17:32:47 -0800170
haoyu7ad5ea32021-03-22 10:36:35 -0700171 private var previousPosition: Offset? = null
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800172
haoyuc40d02752021-01-25 17:32:47 -0800173 /**
Qingqing Deng6f56a912019-05-13 10:10:37 -0700174 * Layout Coordinates of the selection container.
175 */
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100176 var containerLayoutCoordinates: LayoutCoordinates? = null
177 set(value) {
178 field = value
haoyu2c6e9842021-03-30 17:39:04 -0700179 if (hasFocus && selection != null) {
180 val positionInWindow = value?.positionInWindow()
181 if (previousPosition != positionInWindow) {
182 previousPosition = positionInWindow
183 updateHandleOffsets()
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700184 updateSelectionToolbar()
haoyu2c6e9842021-03-30 17:39:04 -0700185 }
haoyu3c3fb452021-02-18 01:01:14 -0800186 }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100187 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700188
189 /**
Qingqing Deng6f56a912019-05-13 10:10:37 -0700190 * The beginning position of the drag gesture. Every time a new drag gesture starts, it wil be
191 * recalculated.
192 */
Zach Klippensteinadabe342021-11-11 16:38:13 -0800193 internal var dragBeginPosition by mutableStateOf(Offset.Zero)
194 private set
Qingqing Deng6f56a912019-05-13 10:10:37 -0700195
196 /**
197 * The total distance being dragged of the drag gesture. Every time a new drag gesture starts,
198 * it will be zeroed out.
199 */
Zach Klippensteinadabe342021-11-11 16:38:13 -0800200 internal var dragTotalDistance by mutableStateOf(Offset.Zero)
201 private set
Qingqing Deng6f56a912019-05-13 10:10:37 -0700202
203 /**
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100204 * The calculated position of the start handle in the [SelectionContainer] coordinates. It
205 * is null when handle shouldn't be displayed.
206 * It is a [State] so reading it during the composition will cause recomposition every time
207 * the position has been changed.
208 */
Zach Klippensteinadabe342021-11-11 16:38:13 -0800209 var startHandlePosition: Offset? by mutableStateOf(null)
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100210 private set
211
212 /**
213 * The calculated position of the end handle in the [SelectionContainer] coordinates. It
214 * is null when handle shouldn't be displayed.
215 * It is a [State] so reading it during the composition will cause recomposition every time
216 * the position has been changed.
217 */
Zach Klippensteinadabe342021-11-11 16:38:13 -0800218 var endHandlePosition: Offset? by mutableStateOf(null)
219 private set
220
221 /**
Zach Klippenstein63870892022-01-14 12:45:18 -0800222 * The handle that is currently being dragged, or null when no handle is being dragged. To get
223 * the position of the last drag event, use [currentDragPosition].
Zach Klippensteinadabe342021-11-11 16:38:13 -0800224 */
225 var draggingHandle: Handle? by mutableStateOf(null)
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100226 private set
227
Zach Klippenstein63870892022-01-14 12:45:18 -0800228 /**
229 * When a handle is being dragged (i.e. [draggingHandle] is non-null), this is the last position
230 * of the actual drag event. It is not clamped to handle positions. Null when not being dragged.
231 */
232 var currentDragPosition: Offset? by mutableStateOf(null)
233 private set
234
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800235 private val shouldShowMagnifier
Grant Toepferd681b5c2023-06-14 11:24:02 -0700236 get() = draggingHandle != null && isInTouchMode && !isTriviallyCollapsedSelection()
237
238 @VisibleForTesting
239 internal var previousSelectionLayout: SelectionLayout? = null
Zach Klippenstein4688a462021-12-08 08:28:07 -0800240
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100241 init {
haoyu7ad5ea32021-03-22 10:36:35 -0700242 selectionRegistrar.onPositionChangeCallback = { selectableId ->
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700243 if (selectableId in selectionRegistrar.subselections) {
haoyu7ad5ea32021-03-22 10:36:35 -0700244 updateHandleOffsets()
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700245 updateSelectionToolbar()
haoyu7ad5ea32021-03-22 10:36:35 -0700246 }
haoyue6d80a12020-12-02 16:04:52 -0800247 }
248
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100249 selectionRegistrar.onSelectionUpdateStartCallback =
Grant Toepferc7d1a4e2023-09-18 23:06:37 +0000250 { isInTouchMode, layoutCoordinates, rawPosition, selectionMode ->
251 val textRect = with(layoutCoordinates.size) {
252 Rect(0f, 0f, width.toFloat(), height.toFloat())
253 }
254
255 val position = if (textRect.containsInclusive(rawPosition)) {
256 rawPosition
257 } else {
258 rawPosition.coerceIn(textRect)
259 }
260
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700261 val positionInContainer = convertToContainerCoordinates(
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100262 layoutCoordinates,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700263 position
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100264 )
265
Grant Toepferd681b5c2023-06-14 11:24:02 -0700266 if (positionInContainer.isSpecified) {
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800267 this.isInTouchMode = isInTouchMode
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700268 startSelection(
269 position = positionInContainer,
270 isStartHandle = false,
271 adjustment = selectionMode
272 )
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100273
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700274 focusRequester.requestFocus()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800275 showToolbar = false
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700276 }
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100277 }
haoyue6d80a12020-12-02 16:04:52 -0800278
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700279 selectionRegistrar.onSelectionUpdateSelectAll =
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800280 { isInTouchMode, selectableId ->
Grant Toepfer8a0c81d2024-04-19 10:26:47 -0700281 val (newSelection, newSubselection) = selectAllInSelectable(
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700282 selectableId = selectableId,
283 previousSelection = selection,
284 )
285 if (newSelection != selection) {
286 selectionRegistrar.subselections = newSubselection
287 onSelectionChange(newSelection)
288 }
289
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800290 this.isInTouchMode = isInTouchMode
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700291 focusRequester.requestFocus()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800292 showToolbar = false
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700293 }
294
haoyue6d80a12020-12-02 16:04:52 -0800295 selectionRegistrar.onSelectionUpdateCallback =
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800296 { isInTouchMode,
297 layoutCoordinates,
298 newPosition,
299 previousPosition,
300 isStartHandle,
301 selectionMode ->
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700302 val newPositionInContainer =
303 convertToContainerCoordinates(layoutCoordinates, newPosition)
304 val previousPositionInContainer =
305 convertToContainerCoordinates(layoutCoordinates, previousPosition)
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100306
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800307 this.isInTouchMode = isInTouchMode
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700308 updateSelection(
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700309 newPosition = newPositionInContainer,
310 previousPosition = previousPositionInContainer,
311 isStartHandle = isStartHandle,
Haoyu Zhang4c6c6372021-04-20 10:25:21 -0700312 adjustment = selectionMode
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700313 )
314 }
haoyue6d80a12020-12-02 16:04:52 -0800315
316 selectionRegistrar.onSelectionUpdateEndCallback = {
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800317 showToolbar = true
Zach Klippensteinadabe342021-11-11 16:38:13 -0800318 // This property is set by updateSelection while dragging, so we need to clear it after
319 // the original selection drag.
320 draggingHandle = null
Zach Klippenstein63870892022-01-14 12:45:18 -0800321 currentDragPosition = null
haoyue6d80a12020-12-02 16:04:52 -0800322 }
haoyu9085c882020-12-08 12:01:06 -0800323
Grant Toepfer2ad83dc2024-02-07 13:50:56 -0800324 // This function is meant to handle changes in the selectable content,
325 // such as the text changing.
haoyue04245e2021-03-08 14:52:56 -0800326 selectionRegistrar.onSelectableChangeCallback = { selectableKey ->
327 if (selectableKey in selectionRegistrar.subselections) {
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700328 // Clear the selection range of each Selectable.
haoyu9085c882020-12-08 12:01:06 -0800329 onRelease()
330 selection = null
331 }
332 }
haoyue04245e2021-03-08 14:52:56 -0800333
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700334 selectionRegistrar.afterSelectableUnsubscribe = { selectableId ->
335 if (selectableId == selection?.start?.selectableId) {
haoyue04245e2021-03-08 14:52:56 -0800336 // The selectable that contains a selection handle just unsubscribed.
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700337 // Hide the associated selection handle
haoyue04245e2021-03-08 14:52:56 -0800338 startHandlePosition = null
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700339 }
340 if (selectableId == selection?.end?.selectableId) {
haoyue04245e2021-03-08 14:52:56 -0800341 endHandlePosition = null
342 }
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700343
344 if (selectableId in selectionRegistrar.subselections) {
345 // Unsubscribing the selectable may make the selection empty, which would hide it.
346 updateSelectionToolbar()
347 }
haoyue04245e2021-03-08 14:52:56 -0800348 }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100349 }
350
Zach Klippensteinadabe342021-11-11 16:38:13 -0800351 /**
352 * Returns the [Selectable] responsible for managing the given [AnchorInfo], or null if the
353 * anchor is not from a currently-registered [Selectable].
354 */
355 internal fun getAnchorSelectable(anchor: AnchorInfo): Selectable? {
356 return selectionRegistrar.selectableMap[anchor.selectableId]
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100357 }
358
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100359 private fun updateHandleOffsets() {
360 val selection = selection
361 val containerCoordinates = containerLayoutCoordinates
Zach Klippensteinadabe342021-11-11 16:38:13 -0800362 val startSelectable = selection?.start?.let(::getAnchorSelectable)
363 val endSelectable = selection?.end?.let(::getAnchorSelectable)
haoyue04245e2021-03-08 14:52:56 -0800364 val startLayoutCoordinates = startSelectable?.getLayoutCoordinates()
365 val endLayoutCoordinates = endSelectable?.getLayoutCoordinates()
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700366
haoyue2678c62020-12-09 08:39:12 -0800367 if (
368 selection == null ||
369 containerCoordinates == null ||
370 !containerCoordinates.isAttached ||
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700371 (startLayoutCoordinates == null && endLayoutCoordinates == null)
haoyue2678c62020-12-09 08:39:12 -0800372 ) {
373 this.startHandlePosition = null
374 this.endHandlePosition = null
375 return
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100376 }
haoyue2678c62020-12-09 08:39:12 -0800377
haoyue2678c62020-12-09 08:39:12 -0800378 val visibleBounds = containerCoordinates.visibleBounds()
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700379 this.startHandlePosition = startLayoutCoordinates?.let { handleCoordinates ->
380 // Set the new handle position only if the handle is in visible bounds or
381 // the handle is still dragging. If handle goes out of visible bounds during drag,
382 // handle popup is also removed from composition, halting the drag gesture. This
383 // affects multiple text selection when selected text is configured with maxLines=1
384 // and overflow=clip.
385 val handlePosition = startSelectable.getHandlePosition(selection, isStartHandle = true)
Grant Toepfer0880f742023-10-11 16:34:51 -0700386 if (handlePosition.isUnspecified) return@let null
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700387 val position = containerCoordinates.localPositionOf(handleCoordinates, handlePosition)
388 position.takeIf {
389 draggingHandle == Handle.SelectionStart || visibleBounds.containsInclusive(it)
390 }
Halil Ozercanb2486f12023-01-09 07:40:22 +0000391 }
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700392
393 this.endHandlePosition = endLayoutCoordinates?.let { handleCoordinates ->
394 val handlePosition = endSelectable.getHandlePosition(selection, isStartHandle = false)
Grant Toepfer0880f742023-10-11 16:34:51 -0700395 if (handlePosition.isUnspecified) return@let null
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700396 val position = containerCoordinates.localPositionOf(handleCoordinates, handlePosition)
397 position.takeIf {
398 draggingHandle == Handle.SelectionEnd || visibleBounds.containsInclusive(it)
399 }
Halil Ozercanb2486f12023-01-09 07:40:22 +0000400 }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100401 }
402
403 /**
404 * Returns non-nullable [containerLayoutCoordinates].
405 */
406 internal fun requireContainerCoordinates(): LayoutCoordinates {
407 val coordinates = containerLayoutCoordinates
Ralston Da Silvaf24ae422023-05-30 12:59:24 -0700408 requireNotNull(coordinates) { "null coordinates" }
409 require(coordinates.isAttached) { "unattached coordinates" }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100410 return coordinates
411 }
412
Grant Toepfer8a0c81d2024-04-19 10:26:47 -0700413 internal fun selectAllInSelectable(
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700414 selectableId: Long,
415 previousSelection: Selection?
Grant Toepfer39043c82024-04-22 11:33:23 -0700416 ): Pair<Selection?, LongObjectMap<Selection>> {
417 val subselections = mutableLongObjectMapOf<Selection>()
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700418 val newSelection = selectionRegistrar.sort(requireContainerCoordinates())
419 .fastFold(null) { mergedSelection: Selection?, selectable: Selectable ->
420 val selection = if (selectable.selectableId == selectableId)
421 selectable.getSelectAllSelection() else null
422 selection?.let { subselections[selectable.selectableId] = it }
423 merge(mergedSelection, selection)
424 }
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800425 if (isInTouchMode && newSelection != previousSelection) {
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700426 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
427 }
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700428 return Pair(newSelection, subselections)
429 }
430
Grant Toepferd681b5c2023-06-14 11:24:02 -0700431 /**
Grant Toepfer8a0c81d2024-04-19 10:26:47 -0700432 * Returns whether the selection encompasses the entire container.
433 */
434 internal fun isEntireContainerSelected(): Boolean {
435 val selectables = selectionRegistrar.sort(requireContainerCoordinates())
436
437 // If there are no selectables, then an empty selection spans the entire container.
438 if (selectables.isEmpty()) return true
439
440 // Since some text exists, we must make sure that every selectable is fully selected.
441 return selectables.fastAll {
442 val text = it.getText()
443 if (text.isEmpty()) return@fastAll true // empty text is inherently fully selected
444
445 // If a non-empty selectable isn't included in the sub-selections,
446 // then some text in the container is not selected.
447 val subSelection = selectionRegistrar.subselections[it.selectableId]
448 ?: return@fastAll false
449
450 val selectionStart = subSelection.start.offset
451 val selectionEnd = subSelection.end.offset
452
453 // The selection could be reversed,
454 // so just verify that the difference between the two offsets matches the text length
455 (selectionStart - selectionEnd).absoluteValue == text.length
456 }
457 }
458
459 /**
460 * Creates and sets a selection spanning the entire container.
461 */
462 internal fun selectAll() {
463 val selectables = selectionRegistrar.sort(requireContainerCoordinates())
464 if (selectables.isEmpty()) return
465
466 var firstSubSelection: Selection? = null
467 var lastSubSelection: Selection? = null
468 val newSubSelections = mutableLongObjectMapOf<Selection>().apply {
469 selectables.fastForEach { selectable ->
470 val subSelection = selectable.getSelectAllSelection() ?: return@fastForEach
471 if (firstSubSelection == null) firstSubSelection = subSelection
472 lastSubSelection = subSelection
473 put(selectable.selectableId, subSelection)
474 }
475 }
476
477 if (newSubSelections.isEmpty()) return
478
479 // first/last sub selections are implied to be non-null from here on out
480 val newSelection = if (firstSubSelection === lastSubSelection) {
481 firstSubSelection
482 } else {
483 Selection(
484 start = firstSubSelection!!.start,
485 end = lastSubSelection!!.end,
486 handlesCrossed = false,
487 )
488 }
489
490 selectionRegistrar.subselections = newSubSelections
491 onSelectionChange(newSelection)
492 previousSelectionLayout = null
493 }
494
495 /**
Grant Toepferd681b5c2023-06-14 11:24:02 -0700496 * Returns whether the start and end anchors are equal.
497 *
498 * It is possible that this returns true, but the selection is still empty because it has
499 * multiple collapsed selections across multiple selectables. To test for that case, use
500 * [isNonEmptySelection].
501 */
502 internal fun isTriviallyCollapsedSelection(): Boolean {
503 val selection = selection ?: return true
504 return selection.start == selection.end
505 }
506
507 /**
508 * Returns whether the selection selects zero characters.
509 *
510 * It is possible that the selection anchors are different but still result in a zero-width
511 * selection. In this case, you may want to still show the selection anchors, but not allow for
512 * a user to try and copy zero characters. To test for whether the anchors are equal, use
513 * [isTriviallyCollapsedSelection].
514 */
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800515 internal fun isNonEmptySelection(): Boolean {
516 val selection = selection ?: return false
Grant Toepferd681b5c2023-06-14 11:24:02 -0700517 if (selection.start == selection.end) {
518 return false
519 }
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800520
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700521 if (selection.start.selectableId == selection.end.selectableId) {
522 // Selection is in the same selectable, but not the same anchors,
523 // so there must be some selected text.
524 return true
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800525 }
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700526
527 // All subselections associated with a selectable must be an empty selection.
528 return selectionRegistrar.sort(requireContainerCoordinates()).fastAny { selectable ->
529 selectionRegistrar.subselections[selectable.selectableId]
530 ?.run { start.offset != end.offset }
531 ?: false
532 }
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800533 }
534
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800535 internal fun getSelectedText(): AnnotatedString? {
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700536 if (selection == null || selectionRegistrar.subselections.isEmpty()) {
537 return null
538 }
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800539
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700540 return buildAnnotatedString {
541 selectionRegistrar.sort(requireContainerCoordinates()).fastForEach { selectable ->
542 selectionRegistrar.subselections[selectable.selectableId]?.let { subSelection ->
543 val currentText = selectable.getText()
544 val currentSelectedText = if (subSelection.handlesCrossed) {
545 currentText.subSequence(
546 subSelection.end.offset,
547 subSelection.start.offset
548 )
549 } else {
550 currentText.subSequence(
551 subSelection.start.offset,
552 subSelection.end.offset
553 )
554 }
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800555
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700556 append(currentSelectedText)
557 }
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800558 }
559 }
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800560 }
561
Qingqing Dengde023cc2020-04-24 14:23:41 -0700562 internal fun copy() {
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700563 getSelectedText()?.takeIf { it.isNotEmpty() }?.let { clipboardManager?.setText(it) }
Qingqing Dengde023cc2020-04-24 14:23:41 -0700564 }
565
566 /**
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800567 * Whether toolbar should be shown right now.
568 * Examples: Show toolbar after user finishes selection.
569 * Hide it during selection.
570 * Hide it when no selection exists.
571 */
572 internal var showToolbar = false
573 internal set(value) {
574 field = value
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700575 updateSelectionToolbar()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800576 }
577
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700578 private fun toolbarCopy() {
579 copy()
580 onRelease()
581 }
582
583 private fun updateSelectionToolbar() {
584 if (!hasFocus) {
585 return
586 }
587
588 val textToolbar = textToolbar ?: return
Grant Toepfer8a0c81d2024-04-19 10:26:47 -0700589 if (showToolbar && isInTouchMode) {
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700590 val rect = getContentRect() ?: return
Grant Toepfer8a0c81d2024-04-19 10:26:47 -0700591 textToolbar.showMenu(
592 rect = rect,
593 onCopyRequested = if (isNonEmptySelection()) ::toolbarCopy else null,
594 onSelectAllRequested = if (isEntireContainerSelected()) null else ::selectAll,
595 )
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700596 } else if (textToolbar.status == TextToolbarStatus.Shown) {
597 textToolbar.hide()
598 }
599 }
600
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800601 /**
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700602 * Calculate selected region as [Rect].
603 * The result is the smallest [Rect] that encapsulates the entire selection,
604 * coerced into visible bounds.
Qingqing Dengde023cc2020-04-24 14:23:41 -0700605 */
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700606 private fun getContentRect(): Rect? {
607 selection ?: return null
608 val containerCoordinates = containerLayoutCoordinates ?: return null
609 if (!containerCoordinates.isAttached) return null
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700610
Grant Toepferef76a142023-11-07 11:56:49 -0700611 val selectableSubSelections = selectionRegistrar.sort(requireContainerCoordinates())
612 .fastMapNotNull { selectable ->
613 selectionRegistrar.subselections[selectable.selectableId]
614 ?.let { selectable to it }
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700615 }
Grant Toepferef76a142023-11-07 11:56:49 -0700616 .firstAndLast()
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700617
Grant Toepferef76a142023-11-07 11:56:49 -0700618 if (selectableSubSelections.isEmpty()) return null
619 val selectedRegionRect =
620 getSelectedRegionRect(selectableSubSelections, containerCoordinates)
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700621
Grant Toepferef76a142023-11-07 11:56:49 -0700622 if (selectedRegionRect == invertedInfiniteRect) return null
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700623
Grant Toepferef76a142023-11-07 11:56:49 -0700624 val visibleRect = containerCoordinates.visibleBounds().intersect(selectedRegionRect)
625 // if the rectangles do not at least touch at the edges, we shouldn't show the toolbar
626 if (visibleRect.width < 0 || visibleRect.height < 0) return null
Grant Toepfer427dbdf2023-09-05 18:38:42 -0700627
Grant Toepferef76a142023-11-07 11:56:49 -0700628 val rootRect = visibleRect.translate(containerCoordinates.positionInRoot())
Albert Changc8462ae2024-04-04 21:46:31 +0900629 return rootRect.copy(bottom = rootRect.bottom + HandleHeight.value * 4)
Qingqing Dengde023cc2020-04-24 14:23:41 -0700630 }
631
Qingqing Dengb6f5d8a2019-11-11 18:19:22 -0800632 // This is for PressGestureDetector to cancel the selection.
633 fun onRelease() {
Grant Toepfer39043c82024-04-22 11:33:23 -0700634 selectionRegistrar.subselections = emptyLongObjectMap()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800635 showToolbar = false
haoyue04245e2021-03-08 14:52:56 -0800636 if (selection != null) {
637 onSelectionChange(null)
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800638 if (isInTouchMode) {
639 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
640 }
haoyue04245e2021-03-08 14:52:56 -0800641 }
Qingqing Dengb6f5d8a2019-11-11 18:19:22 -0800642 }
643
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800644 fun handleDragObserver(isStartHandle: Boolean): TextDragObserver = object : TextDragObserver {
645 override fun onDown(point: Offset) {
Grant Toepferc9a7da32023-10-19 14:53:25 -0700646 // if the handle position is null, then it is invisible, so ignore the gesture
647 (if (isStartHandle) startHandlePosition else endHandlePosition) ?: return
648
Zach Klippenstein63870892022-01-14 12:45:18 -0800649 val selection = selection ?: return
650 val anchor = if (isStartHandle) selection.start else selection.end
651 val selectable = getAnchorSelectable(anchor) ?: return
652 // The LayoutCoordinates of the composable where the drag gesture should begin. This
653 // is used to convert the position of the beginning of the drag gesture from the
654 // composable coordinates to selection container coordinates.
655 val beginLayoutCoordinates = selectable.getLayoutCoordinates() ?: return
656
657 // The position of the character where the drag gesture should begin. This is in
658 // the composable coordinates.
Grant Toepferc9a7da32023-10-19 14:53:25 -0700659 val handlePosition = selectable.getHandlePosition(selection, isStartHandle)
660 if (handlePosition.isUnspecified) return
661 val beginCoordinates = getAdjustedCoordinates(handlePosition)
Zach Klippenstein63870892022-01-14 12:45:18 -0800662
663 // Convert the position where drag gesture begins from composable coordinates to
664 // selection container coordinates.
665 currentDragPosition = requireContainerCoordinates().localPositionOf(
666 beginLayoutCoordinates,
667 beginCoordinates
668 )
669 draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800670 showToolbar = false
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800671 }
672
673 override fun onStart(startPoint: Offset) {
Grant Toepferc9a7da32023-10-19 14:53:25 -0700674 draggingHandle ?: return
675
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800676 val selection = selection!!
Grant Toepferc9a7da32023-10-19 14:53:25 -0700677 val anchor = if (isStartHandle) selection.start else selection.end
678 val selectable = checkNotNull(selectionRegistrar.selectableMap[anchor.selectableId]) {
679 "SelectionRegistrar should contain the current selection's selectableIds"
680 }
681
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800682 // The LayoutCoordinates of the composable where the drag gesture should begin. This
683 // is used to convert the position of the beginning of the drag gesture from the
684 // composable coordinates to selection container coordinates.
Grant Toepferc9a7da32023-10-19 14:53:25 -0700685 val beginLayoutCoordinates = checkNotNull(selectable.getLayoutCoordinates()) {
686 "Current selectable should have layout coordinates."
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800687 }
688
689 // The position of the character where the drag gesture should begin. This is in
690 // the composable coordinates.
Grant Toepferc9a7da32023-10-19 14:53:25 -0700691 val handlePosition = selectable.getHandlePosition(selection, isStartHandle)
692 if (handlePosition.isUnspecified) return
693 val beginCoordinates = getAdjustedCoordinates(handlePosition)
Siyamed Sinir472c3162019-10-21 23:41:00 -0700694
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800695 // Convert the position where drag gesture begins from composable coordinates to
696 // selection container coordinates.
697 dragBeginPosition = requireContainerCoordinates().localPositionOf(
698 beginLayoutCoordinates,
699 beginCoordinates
700 )
Siyamed Sinir472c3162019-10-21 23:41:00 -0700701
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800702 // Zero out the total distance that being dragged.
703 dragTotalDistance = Offset.Zero
704 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700705
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800706 override fun onDrag(delta: Offset) {
Grant Toepferc9a7da32023-10-19 14:53:25 -0700707 draggingHandle ?: return
708
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800709 dragTotalDistance += delta
710 val endPosition = dragBeginPosition + dragTotalDistance
711 val consumed = updateSelection(
712 newPosition = endPosition,
713 previousPosition = dragBeginPosition,
714 isStartHandle = isStartHandle,
715 adjustment = SelectionAdjustment.CharacterWithWordAccelerate
716 )
717 if (consumed) {
718 dragBeginPosition = endPosition
Nader Jawad6df06122020-06-03 15:27:08 -0700719 dragTotalDistance = Offset.Zero
Qingqing Deng6f56a912019-05-13 10:10:37 -0700720 }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800721 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700722
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800723 private fun done() {
724 showToolbar = true
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800725 draggingHandle = null
Zach Klippenstein63870892022-01-14 12:45:18 -0800726 currentDragPosition = null
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800727 }
haoyue6d80a12020-12-02 16:04:52 -0800728
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800729 override fun onUp() = done()
730 override fun onStop() = done()
731 override fun onCancel() = done()
Qingqing Deng6f56a912019-05-13 10:10:37 -0700732 }
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700733
Ralston Da Silvade62bc62021-06-02 17:46:44 -0700734 /**
735 * Detect tap without consuming the up event.
736 */
737 private suspend fun PointerInputScope.detectNonConsumingTap(onTap: (Offset) -> Unit) {
George Mount32de9dd2022-10-05 14:51:06 -0700738 awaitEachGesture {
739 waitForUpOrCancellation()?.let {
740 onTap(it.position)
Ralston Da Silvade62bc62021-06-02 17:46:44 -0700741 }
742 }
743 }
744
745 private fun Modifier.onClearSelectionRequested(block: () -> Unit): Modifier {
746 return if (hasFocus) pointerInput(Unit) { detectNonConsumingTap { block() } } else this
747 }
748
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700749 private fun convertToContainerCoordinates(
750 layoutCoordinates: LayoutCoordinates,
751 offset: Offset
Grant Toepferd681b5c2023-06-14 11:24:02 -0700752 ): Offset {
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700753 val coordinates = containerLayoutCoordinates
Grant Toepferd681b5c2023-06-14 11:24:02 -0700754 if (coordinates == null || !coordinates.isAttached) return Offset.Unspecified
George Mount77ca2a22020-12-11 17:46:19 +0000755 return requireContainerCoordinates().localPositionOf(layoutCoordinates, offset)
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700756 }
757
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700758 /**
759 * Cancel the previous selection and start a new selection at the given [position].
760 * It's used for long-press, double-click, triple-click and so on to start selection.
761 *
762 * @param position initial position of the selection. Both start and end handle is considered
763 * at this position.
764 * @param isStartHandle whether it's considered as the start handle moving. This parameter
765 * will influence the [SelectionAdjustment]'s behavior. For example,
766 * [SelectionAdjustment.Character] only adjust the moving handle.
767 * @param adjustment the selection adjustment.
768 */
769 private fun startSelection(
770 position: Offset,
771 isStartHandle: Boolean,
772 adjustment: SelectionAdjustment
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700773 ) {
Grant Toepferd681b5c2023-06-14 11:24:02 -0700774 previousSelectionLayout = null
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700775 updateSelection(
Grant Toepfer3fb01402023-10-13 12:44:44 -0700776 position = position,
Grant Toepferd681b5c2023-06-14 11:24:02 -0700777 previousHandlePosition = Offset.Unspecified,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700778 isStartHandle = isStartHandle,
779 adjustment = adjustment
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700780 )
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700781 }
Haoyu Zhanga78608e2021-08-03 17:10:18 -0700782
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700783 /**
784 * Updates the selection after one of the selection handle moved.
785 *
786 * @param newPosition the new position of the moving selection handle.
787 * @param previousPosition the previous position of the moving selection handle.
788 * @param isStartHandle whether the moving selection handle is the start handle.
789 * @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
790 * produce the final selection range.
791 *
792 * @return a boolean representing whether the movement is consumed.
793 *
794 * @see SelectionAdjustment
795 */
796 internal fun updateSelection(
797 newPosition: Offset?,
Grant Toepferd681b5c2023-06-14 11:24:02 -0700798 previousPosition: Offset,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700799 isStartHandle: Boolean,
800 adjustment: SelectionAdjustment,
801 ): Boolean {
802 if (newPosition == null) return false
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700803 return updateSelection(
Grant Toepfer3fb01402023-10-13 12:44:44 -0700804 position = newPosition,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700805 previousHandlePosition = previousPosition,
806 isStartHandle = isStartHandle,
807 adjustment = adjustment
808 )
809 }
810
811 /**
812 * Updates the selection after one of the selection handle moved.
813 *
814 * To make sure that [SelectionAdjustment] works correctly, it's expected that only one
815 * selection handle is updated each time. The only exception is that when a new selection is
816 * started. In this case, [previousHandlePosition] is always null.
817 *
Grant Toepfer3fb01402023-10-13 12:44:44 -0700818 * @param position the position of the current gesture.
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700819 * @param previousHandlePosition the position of the moving handle before the update.
820 * @param isStartHandle whether the moving selection handle is the start handle.
821 * @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
822 * produce the final selection range.
823 *
824 * @return a boolean representing whether the movement is consumed. It's useful for the case
825 * where a selection handle is updating consecutively. When the return value is true, it's
826 * expected that the caller will update the [startHandlePosition] to be the given
827 * [endHandlePosition] in following calls.
828 *
829 * @see SelectionAdjustment
830 */
831 internal fun updateSelection(
Grant Toepfer3fb01402023-10-13 12:44:44 -0700832 position: Offset,
Grant Toepferd681b5c2023-06-14 11:24:02 -0700833 previousHandlePosition: Offset,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700834 isStartHandle: Boolean,
835 adjustment: SelectionAdjustment,
836 ): Boolean {
Zach Klippensteinadabe342021-11-11 16:38:13 -0800837 draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
Grant Toepfer3fb01402023-10-13 12:44:44 -0700838 currentDragPosition = position
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700839
Grant Toepfer3fb01402023-10-13 12:44:44 -0700840 val selectionLayout = getSelectionLayout(position, previousHandlePosition, isStartHandle)
Grant Toepferd681b5c2023-06-14 11:24:02 -0700841 if (!selectionLayout.shouldRecomputeSelection(previousSelectionLayout)) {
842 return false
Haoyu Zhanga78608e2021-08-03 17:10:18 -0700843 }
Grant Toepferd681b5c2023-06-14 11:24:02 -0700844
845 val newSelection = adjustment.adjust(selectionLayout)
846 if (newSelection != selection) {
847 selectionChanged(selectionLayout, newSelection)
848 }
849 previousSelectionLayout = selectionLayout
850 return true
Haoyu Zhanga78608e2021-08-03 17:10:18 -0700851 }
Andrey Rudenkof3906a02021-10-12 13:23:40 +0200852
Grant Toepferd681b5c2023-06-14 11:24:02 -0700853 private fun getSelectionLayout(
Grant Toepfer3fb01402023-10-13 12:44:44 -0700854 position: Offset,
Grant Toepferd681b5c2023-06-14 11:24:02 -0700855 previousHandlePosition: Offset,
856 isStartHandle: Boolean,
857 ): SelectionLayout {
858 val containerCoordinates = requireContainerCoordinates()
859 val sortedSelectables = selectionRegistrar.sort(containerCoordinates)
860
Grant Toepfer39043c82024-04-22 11:33:23 -0700861 val idToIndexMap = mutableLongIntMapOf()
Grant Toepferd681b5c2023-06-14 11:24:02 -0700862 sortedSelectables.fastForEachIndexed { index, selectable ->
863 idToIndexMap[selectable.selectableId] = index
864 }
865
866 val selectableIdOrderingComparator = compareBy<Long> { idToIndexMap[it] }
867
868 // if previous handle is null, then treat this as a new selection.
869 val previousSelection = if (previousHandlePosition.isUnspecified) null else selection
870 val builder = SelectionLayoutBuilder(
Grant Toepfer3fb01402023-10-13 12:44:44 -0700871 currentPosition = position,
Grant Toepferd681b5c2023-06-14 11:24:02 -0700872 previousHandlePosition = previousHandlePosition,
873 containerCoordinates = containerCoordinates,
874 isStartHandle = isStartHandle,
875 previousSelection = previousSelection,
876 selectableIdOrderingComparator = selectableIdOrderingComparator,
877 )
878
879 sortedSelectables.fastForEach {
880 it.appendSelectableInfoToBuilder(builder)
881 }
882
883 return builder.build()
884 }
885
886 private fun selectionChanged(selectionLayout: SelectionLayout, newSelection: Selection) {
887 if (shouldPerformHaptics()) {
888 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
889 }
890 selectionRegistrar.subselections = selectionLayout.createSubSelections(newSelection)
891 onSelectionChange(newSelection)
892 }
893
894 @VisibleForTesting
895 internal fun shouldPerformHaptics(): Boolean =
896 isInTouchMode && selectionRegistrar.selectables.fastAny { it.getText().isNotEmpty() }
897
Andrey Rudenkof3906a02021-10-12 13:23:40 +0200898 fun contextMenuOpenAdjustment(position: Offset) {
899 val isEmptySelection = selection?.toTextRange()?.collapsed ?: true
900 // TODO(b/209483184) the logic should be more complex here, it should check that current
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800901 // selection doesn't include click position
Andrey Rudenkof3906a02021-10-12 13:23:40 +0200902 if (isEmptySelection) {
903 startSelection(
904 position = position,
905 isStartHandle = true,
906 adjustment = SelectionAdjustment.Word
907 )
908 }
909 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700910}
911
Andrey Rudenko5dc7aad2020-09-18 10:33:37 +0200912internal fun merge(lhs: Selection?, rhs: Selection?): Selection? {
Siyamed Sinir700df8452019-10-22 20:23:58 -0700913 return lhs?.merge(rhs) ?: rhs
914}
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800915
Andrey Rudenko8b4981e2021-01-29 15:31:59 +0100916internal expect fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean
917
Zach Klippensteinadabe342021-11-11 16:38:13 -0800918internal expect fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier
919
Grant Toepferef76a142023-11-07 11:56:49 -0700920private val invertedInfiniteRect = Rect(
921 left = Float.POSITIVE_INFINITY,
922 top = Float.POSITIVE_INFINITY,
923 right = Float.NEGATIVE_INFINITY,
924 bottom = Float.NEGATIVE_INFINITY
925)
926
927private fun <T> List<T>.firstAndLast(): List<T> = when (size) {
928 0, 1 -> this
929 else -> listOf(first(), last())
930}
931
932/**
933 * Get the selected region rect in the given [containerCoordinates].
934 * This will compute the smallest rect that contains every first/last
935 * character bounding box of each selectable. If for any reason there are no
936 * bounding boxes, then the [invertedInfiniteRect] is returned.
937 */
938@VisibleForTesting
939internal fun getSelectedRegionRect(
940 selectableSubSelectionPairs: List<Pair<Selectable, Selection>>,
941 containerCoordinates: LayoutCoordinates,
942): Rect {
943 if (selectableSubSelectionPairs.isEmpty()) return invertedInfiniteRect
944 var (containerLeft, containerTop, containerRight, containerBottom) = invertedInfiniteRect
945 selectableSubSelectionPairs.fastForEach { (selectable, subSelection) ->
946 val startOffset = subSelection.start.offset
947 val endOffset = subSelection.end.offset
948 if (startOffset == endOffset) return@fastForEach
949 val localCoordinates = selectable.getLayoutCoordinates() ?: return@fastForEach
950
951 val minOffset = minOf(startOffset, endOffset)
952 val maxOffset = maxOf(startOffset, endOffset)
953 val offsets = if (minOffset == maxOffset - 1) {
954 intArrayOf(minOffset)
955 } else {
956 intArrayOf(minOffset, maxOffset - 1)
957 }
958 var (left, top, right, bottom) = invertedInfiniteRect
959 for (i in offsets) {
960 val rect = selectable.getBoundingBox(i)
961 left = minOf(left, rect.left)
962 top = minOf(top, rect.top)
963 right = maxOf(right, rect.right)
964 bottom = maxOf(bottom, rect.bottom)
965 }
966
967 val localTopLeft = Offset(left, top)
968 val localBottomRight = Offset(right, bottom)
969
970 val containerTopLeft =
971 containerCoordinates.localPositionOf(localCoordinates, localTopLeft)
972 val containerBottomRight =
973 containerCoordinates.localPositionOf(localCoordinates, localBottomRight)
974
975 containerLeft = minOf(containerLeft, containerTopLeft.x)
976 containerTop = minOf(containerTop, containerTopLeft.y)
977 containerRight = maxOf(containerRight, containerBottomRight.x)
978 containerBottom = maxOf(containerBottom, containerBottomRight.y)
979 }
980 return Rect(containerLeft, containerTop, containerRight, containerBottom)
981}
982
Zach Klippenstein9c32bdf2021-11-11 16:38:13 -0800983internal fun calculateSelectionMagnifierCenterAndroid(
984 manager: SelectionManager,
985 magnifierSize: IntSize
986): Offset {
Zach Klippensteinadabe342021-11-11 16:38:13 -0800987 val selection = manager.selection ?: return Offset.Unspecified
988 return when (manager.draggingHandle) {
989 null -> return Offset.Unspecified
Grant Toepferd681b5c2023-06-14 11:24:02 -0700990 Handle.SelectionStart -> getMagnifierCenter(manager, magnifierSize, selection.start)
991 Handle.SelectionEnd -> getMagnifierCenter(manager, magnifierSize, selection.end)
Zach Klippensteinadabe342021-11-11 16:38:13 -0800992 Handle.Cursor -> error("SelectionContainer does not support cursor")
993 }
994}
995
Grant Toepferd681b5c2023-06-14 11:24:02 -0700996private fun getMagnifierCenter(
997 manager: SelectionManager,
998 magnifierSize: IntSize,
999 anchor: AnchorInfo
1000): Offset {
1001 val selectable = manager.getAnchorSelectable(anchor) ?: return Offset.Unspecified
1002 val containerCoordinates = manager.containerLayoutCoordinates ?: return Offset.Unspecified
1003 val selectableCoordinates = selectable.getLayoutCoordinates() ?: return Offset.Unspecified
1004 val offset = anchor.offset
1005
1006 if (offset > selectable.getLastVisibleOffset()) return Offset.Unspecified
1007
1008 // The horizontal position doesn't snap to cursor positions but should directly track the
1009 // actual drag.
1010 val localDragPosition = selectableCoordinates.localPositionOf(
1011 containerCoordinates,
1012 manager.currentDragPosition!!
1013 )
1014 val dragX = localDragPosition.x
1015
1016 // But it is constrained by the horizontal bounds of the current line.
1017 val lineRange = selectable.getRangeOfLineContaining(offset)
1018 val textConstrainedX = if (lineRange.collapsed) {
1019 // A collapsed range implies the text is empty.
1020 // line left and right are equal for this offset, so use either
1021 selectable.getLineLeft(offset)
1022 } else {
1023 val lineStartX = selectable.getLineLeft(lineRange.start)
1024 val lineEndX = selectable.getLineRight(lineRange.end - 1)
1025 // in RTL/BiDi, lineStartX may be larger than lineEndX
1026 val minX = minOf(lineStartX, lineEndX)
1027 val maxX = maxOf(lineStartX, lineEndX)
1028 dragX.coerceIn(minX, maxX)
1029 }
1030
1031 // selectable couldn't determine horizontals
1032 if (textConstrainedX == -1f) return Offset.Unspecified
1033
1034 // Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
1035 // magnifier actually is). See
1036 // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
Halil Ozercan65e600c2024-04-24 11:52:51 +01001037 // Also check whether magnifierSize is calculated. A platform magnifier instance is not
1038 // created until it's requested for the first time. So the size will only be calculated after we
1039 // return a specified offset from this function.
1040 // It is very unlikely that this behavior would cause a flicker since magnifier immediately
1041 // shows up where the pointer is being dragged. The pointer needs to drag further than the half
1042 // of magnifier's width to hide by the following logic.
1043 if (magnifierSize != IntSize.Zero &&
1044 (dragX - textConstrainedX).absoluteValue > magnifierSize.width / 2) {
Grant Toepferd681b5c2023-06-14 11:24:02 -07001045 return Offset.Unspecified
1046 }
1047
1048 val lineCenterY = selectable.getCenterYForOffset(offset)
1049
1050 // selectable couldn't determine the line center
1051 if (lineCenterY == -1f) return Offset.Unspecified
1052
1053 return containerCoordinates.localPositionOf(
1054 sourceCoordinates = selectableCoordinates,
1055 relativeToSource = Offset(textConstrainedX, lineCenterY)
1056 )
1057}
1058
haoyue2678c62020-12-09 08:39:12 -08001059/** Returns the boundary of the visible area in this [LayoutCoordinates]. */
haoyuac341f02021-01-22 22:01:56 -08001060internal fun LayoutCoordinates.visibleBounds(): Rect {
haoyue2678c62020-12-09 08:39:12 -08001061 // globalBounds is the global boundaries of this LayoutCoordinates after it's clipped by
1062 // parents. We can think it as the global visible bounds of this Layout. Here globalBounds
1063 // is convert to local, which is the boundary of the visible area within the LayoutCoordinates.
George Mount77ca2a22020-12-11 17:46:19 +00001064 val boundsInWindow = boundsInWindow()
haoyue2678c62020-12-09 08:39:12 -08001065 return Rect(
George Mount77ca2a22020-12-11 17:46:19 +00001066 windowToLocal(boundsInWindow.topLeft),
1067 windowToLocal(boundsInWindow.bottomRight)
haoyue2678c62020-12-09 08:39:12 -08001068 )
1069}
1070
haoyuac341f02021-01-22 22:01:56 -08001071internal fun Rect.containsInclusive(offset: Offset): Boolean =
haoyue2678c62020-12-09 08:39:12 -08001072 offset.x in left..right && offset.y in top..bottom