[go: nahoru, domu]

blob: a02f411fb412f763dcfc712dfd3228ad06c04ac5 [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
haoyuc40d02752021-01-25 17:32:47 -080020import androidx.compose.foundation.focusable
George Mount32de9dd2022-10-05 14:51:06 -070021import androidx.compose.foundation.gestures.awaitEachGesture
Ralston Da Silvade62bc62021-06-02 17:46:44 -070022import androidx.compose.foundation.gestures.waitForUpOrCancellation
Zach Klippensteinadabe342021-11-11 16:38:13 -080023import androidx.compose.foundation.text.Handle
24import androidx.compose.foundation.text.TextDragObserver
25import androidx.compose.foundation.text.selection.Selection.AnchorInfo
26import androidx.compose.runtime.MutableState
Louis Pullen-Freilich1f10a592020-07-24 16:35:14 +010027import androidx.compose.runtime.State
28import androidx.compose.runtime.getValue
29import androidx.compose.runtime.mutableStateOf
30import androidx.compose.runtime.setValue
haoyuc40d02752021-01-25 17:32:47 -080031import androidx.compose.ui.Modifier
32import androidx.compose.ui.focus.FocusRequester
33import androidx.compose.ui.focus.focusRequester
haoyuc40d02752021-01-25 17:32:47 -080034import androidx.compose.ui.focus.onFocusChanged
Louis Pullen-Freilich534385a2020-08-14 14:10:12 +010035import androidx.compose.ui.geometry.Offset
36import androidx.compose.ui.geometry.Rect
Grant Toepferd681b5c2023-06-14 11:24:02 -070037import androidx.compose.ui.geometry.isSpecified
38import androidx.compose.ui.geometry.isUnspecified
Louis Pullen-Freilicha03fd6c2020-07-24 23:26:29 +010039import androidx.compose.ui.hapticfeedback.HapticFeedback
40import androidx.compose.ui.hapticfeedback.HapticFeedbackType
Andrey Rudenko8b4981e2021-01-29 15:31:59 +010041import androidx.compose.ui.input.key.KeyEvent
42import androidx.compose.ui.input.key.onKeyEvent
Ralston Da Silvade62bc62021-06-02 17:46:44 -070043import androidx.compose.ui.input.pointer.PointerInputScope
44import androidx.compose.ui.input.pointer.pointerInput
Louis Pullen-Freilich534385a2020-08-14 14:10:12 +010045import androidx.compose.ui.layout.LayoutCoordinates
George Mount77ca2a22020-12-11 17:46:19 +000046import androidx.compose.ui.layout.boundsInWindow
haoyuc40d02752021-01-25 17:32:47 -080047import androidx.compose.ui.layout.onGloballyPositioned
haoyu7ad5ea32021-03-22 10:36:35 -070048import androidx.compose.ui.layout.positionInWindow
Louis Pullen-Freilich534385a2020-08-14 14:10:12 +010049import androidx.compose.ui.platform.ClipboardManager
Louis Pullen-Freilicha03fd6c2020-07-24 23:26:29 +010050import androidx.compose.ui.platform.TextToolbar
51import androidx.compose.ui.platform.TextToolbarStatus
Louis Pullen-Freilichab194752020-07-21 22:21:22 +010052import androidx.compose.ui.text.AnnotatedString
Zach Klippenstein9c32bdf2021-11-11 16:38:13 -080053import androidx.compose.ui.unit.IntSize
Grant Toepferd681b5c2023-06-14 11:24:02 -070054import androidx.compose.ui.util.fastAny
Louis Pullen-Freilich1ffdbfc2023-08-24 18:35:34 +010055import androidx.compose.ui.util.fastFold
Grant Toepferfefbd7a2023-03-03 15:02:55 -080056import androidx.compose.ui.util.fastForEach
Grant Toepferd681b5c2023-06-14 11:24:02 -070057import androidx.compose.ui.util.fastForEachIndexed
Zach Klippenstein9c32bdf2021-11-11 16:38:13 -080058import kotlin.math.absoluteValue
Nader Jawade6a9b332020-05-21 13:49:20 -070059import kotlin.math.max
60import kotlin.math.min
Qingqing Deng6f56a912019-05-13 10:10:37 -070061
Qingqing Deng35f97ea2019-09-18 19:24:37 -070062/**
Louis Pullen-Freilich5da28bd2019-10-15 17:05:07 +010063 * A bridge class between user interaction to the text composables for text selection.
Qingqing Deng35f97ea2019-09-18 19:24:37 -070064 */
Siyamed Sinir700df8452019-10-22 20:23:58 -070065internal class SelectionManager(private val selectionRegistrar: SelectionRegistrarImpl) {
Zach Klippensteinadabe342021-11-11 16:38:13 -080066
67 private val _selection: MutableState<Selection?> = mutableStateOf(null)
68
Qingqing Deng6f56a912019-05-13 10:10:37 -070069 /**
70 * The current selection.
71 */
Zach Klippensteinadabe342021-11-11 16:38:13 -080072 var selection: Selection?
73 get() = _selection.value
Andrey Kulikovb8c7e052020-04-15 19:20:09 +010074 set(value) {
Zach Klippensteinadabe342021-11-11 16:38:13 -080075 _selection.value = value
haoyu9085c882020-12-08 12:01:06 -080076 if (value != null) {
77 updateHandleOffsets()
78 }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +010079 }
Qingqing Deng6f56a912019-05-13 10:10:37 -070080
81 /**
Andrey Rudenko8b4981e2021-01-29 15:31:59 +010082 * Is touch mode active
83 */
Grant Toepferfefbd7a2023-03-03 15:02:55 -080084 private val _isInTouchMode = mutableStateOf(true)
85 var isInTouchMode: Boolean
86 get() = _isInTouchMode.value
87 set(value) {
88 if (_isInTouchMode.value != value) {
89 _isInTouchMode.value = value
90 if (value && showToolbar) showSelectionToolbar() else hideSelectionToolbar()
91 }
92 }
Andrey Rudenko8b4981e2021-01-29 15:31:59 +010093
94 /**
Qingqing Deng6f56a912019-05-13 10:10:37 -070095 * The manager will invoke this every time it comes to the conclusion that the selection should
96 * change. The expectation is that this callback will end up causing `setSelection` to get
97 * called. This is what makes this a "controlled component".
98 */
99 var onSelectionChange: (Selection?) -> Unit = {}
100
101 /**
Qingqing Deng25b8f8d2020-01-17 16:36:19 -0800102 * [HapticFeedback] handle to perform haptic feedback.
103 */
104 var hapticFeedBack: HapticFeedback? = null
105
106 /**
Qingqing Dengf2d0a2d2020-03-12 14:19:50 -0700107 * [ClipboardManager] to perform clipboard features.
108 */
109 var clipboardManager: ClipboardManager? = null
110
111 /**
Qingqing Denga89450d2020-04-03 19:11:49 -0700112 * [TextToolbar] to show floating toolbar(post-M) or primary toolbar(pre-M).
113 */
114 var textToolbar: TextToolbar? = null
115
116 /**
haoyuc40d02752021-01-25 17:32:47 -0800117 * Focus requester used to request focus when selection becomes active.
118 */
119 var focusRequester: FocusRequester = FocusRequester()
120
121 /**
haoyu3c3fb452021-02-18 01:01:14 -0800122 * Return true if the corresponding SelectionContainer is focused.
haoyuc40d02752021-01-25 17:32:47 -0800123 */
haoyu3c3fb452021-02-18 01:01:14 -0800124 var hasFocus: Boolean by mutableStateOf(false)
haoyuc40d02752021-01-25 17:32:47 -0800125
126 /**
127 * Modifier for selection container.
128 */
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800129 val modifier
130 get() = Modifier
131 .onClearSelectionRequested { onRelease() }
132 .onGloballyPositioned { containerLayoutCoordinates = it }
133 .focusRequester(focusRequester)
134 .onFocusChanged { focusState ->
135 if (!focusState.isFocused && hasFocus) {
136 onRelease()
137 }
138 hasFocus = focusState.isFocused
haoyuc40d02752021-01-25 17:32:47 -0800139 }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800140 .focusable()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800141 .updateSelectionTouchMode { isInTouchMode = it }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800142 .onKeyEvent {
143 if (isCopyKeyEvent(it)) {
144 copy()
145 true
146 } else {
147 false
148 }
Andrey Rudenko8b4981e2021-01-29 15:31:59 +0100149 }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800150 .then(if (shouldShowMagnifier) Modifier.selectionMagnifier(this) else Modifier)
haoyuc40d02752021-01-25 17:32:47 -0800151
haoyu7ad5ea32021-03-22 10:36:35 -0700152 private var previousPosition: Offset? = null
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800153
haoyuc40d02752021-01-25 17:32:47 -0800154 /**
Qingqing Deng6f56a912019-05-13 10:10:37 -0700155 * Layout Coordinates of the selection container.
156 */
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100157 var containerLayoutCoordinates: LayoutCoordinates? = null
158 set(value) {
159 field = value
haoyu2c6e9842021-03-30 17:39:04 -0700160 if (hasFocus && selection != null) {
161 val positionInWindow = value?.positionInWindow()
162 if (previousPosition != positionInWindow) {
163 previousPosition = positionInWindow
164 updateHandleOffsets()
165 updateSelectionToolbarPosition()
166 }
haoyu3c3fb452021-02-18 01:01:14 -0800167 }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100168 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700169
170 /**
Qingqing Deng6f56a912019-05-13 10:10:37 -0700171 * The beginning position of the drag gesture. Every time a new drag gesture starts, it wil be
172 * recalculated.
173 */
Zach Klippensteinadabe342021-11-11 16:38:13 -0800174 internal var dragBeginPosition by mutableStateOf(Offset.Zero)
175 private set
Qingqing Deng6f56a912019-05-13 10:10:37 -0700176
177 /**
178 * The total distance being dragged of the drag gesture. Every time a new drag gesture starts,
179 * it will be zeroed out.
180 */
Zach Klippensteinadabe342021-11-11 16:38:13 -0800181 internal var dragTotalDistance by mutableStateOf(Offset.Zero)
182 private set
Qingqing Deng6f56a912019-05-13 10:10:37 -0700183
184 /**
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100185 * The calculated position of the start handle in the [SelectionContainer] coordinates. It
186 * is null when handle shouldn't be displayed.
187 * It is a [State] so reading it during the composition will cause recomposition every time
188 * the position has been changed.
189 */
Zach Klippensteinadabe342021-11-11 16:38:13 -0800190 var startHandlePosition: Offset? by mutableStateOf(null)
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100191 private set
192
193 /**
194 * The calculated position of the end handle in the [SelectionContainer] coordinates. It
195 * is null when handle shouldn't be displayed.
196 * It is a [State] so reading it during the composition will cause recomposition every time
197 * the position has been changed.
198 */
Zach Klippensteinadabe342021-11-11 16:38:13 -0800199 var endHandlePosition: Offset? by mutableStateOf(null)
200 private set
201
202 /**
Zach Klippenstein63870892022-01-14 12:45:18 -0800203 * The handle that is currently being dragged, or null when no handle is being dragged. To get
204 * the position of the last drag event, use [currentDragPosition].
Zach Klippensteinadabe342021-11-11 16:38:13 -0800205 */
206 var draggingHandle: Handle? by mutableStateOf(null)
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100207 private set
208
Zach Klippenstein63870892022-01-14 12:45:18 -0800209 /**
210 * When a handle is being dragged (i.e. [draggingHandle] is non-null), this is the last position
211 * of the actual drag event. It is not clamped to handle positions. Null when not being dragged.
212 */
213 var currentDragPosition: Offset? by mutableStateOf(null)
214 private set
215
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800216 private val shouldShowMagnifier
Grant Toepferd681b5c2023-06-14 11:24:02 -0700217 get() = draggingHandle != null && isInTouchMode && !isTriviallyCollapsedSelection()
218
219 @VisibleForTesting
220 internal var previousSelectionLayout: SelectionLayout? = null
Zach Klippenstein4688a462021-12-08 08:28:07 -0800221
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100222 init {
haoyu7ad5ea32021-03-22 10:36:35 -0700223 selectionRegistrar.onPositionChangeCallback = { selectableId ->
224 if (
225 selectableId == selection?.start?.selectableId ||
226 selectableId == selection?.end?.selectableId
227 ) {
228 updateHandleOffsets()
229 updateSelectionToolbarPosition()
230 }
haoyue6d80a12020-12-02 16:04:52 -0800231 }
232
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100233 selectionRegistrar.onSelectionUpdateStartCallback =
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800234 { isInTouchMode, layoutCoordinates, position, selectionMode ->
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700235 val positionInContainer = convertToContainerCoordinates(
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100236 layoutCoordinates,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700237 position
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100238 )
239
Grant Toepferd681b5c2023-06-14 11:24:02 -0700240 if (positionInContainer.isSpecified) {
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800241 this.isInTouchMode = isInTouchMode
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700242 startSelection(
243 position = positionInContainer,
244 isStartHandle = false,
245 adjustment = selectionMode
246 )
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100247
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700248 focusRequester.requestFocus()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800249 showToolbar = false
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700250 }
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100251 }
haoyue6d80a12020-12-02 16:04:52 -0800252
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700253 selectionRegistrar.onSelectionUpdateSelectAll =
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800254 { isInTouchMode, selectableId ->
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700255 val (newSelection, newSubselection) = selectAll(
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700256 selectableId = selectableId,
257 previousSelection = selection,
258 )
259 if (newSelection != selection) {
260 selectionRegistrar.subselections = newSubselection
261 onSelectionChange(newSelection)
262 }
263
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800264 this.isInTouchMode = isInTouchMode
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700265 focusRequester.requestFocus()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800266 showToolbar = false
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700267 }
268
haoyue6d80a12020-12-02 16:04:52 -0800269 selectionRegistrar.onSelectionUpdateCallback =
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800270 { isInTouchMode,
271 layoutCoordinates,
272 newPosition,
273 previousPosition,
274 isStartHandle,
275 selectionMode ->
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700276 val newPositionInContainer =
277 convertToContainerCoordinates(layoutCoordinates, newPosition)
278 val previousPositionInContainer =
279 convertToContainerCoordinates(layoutCoordinates, previousPosition)
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100280
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800281 this.isInTouchMode = isInTouchMode
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700282 updateSelection(
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700283 newPosition = newPositionInContainer,
284 previousPosition = previousPositionInContainer,
285 isStartHandle = isStartHandle,
Haoyu Zhang4c6c6372021-04-20 10:25:21 -0700286 adjustment = selectionMode
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700287 )
288 }
haoyue6d80a12020-12-02 16:04:52 -0800289
290 selectionRegistrar.onSelectionUpdateEndCallback = {
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800291 showToolbar = true
Zach Klippensteinadabe342021-11-11 16:38:13 -0800292 // This property is set by updateSelection while dragging, so we need to clear it after
293 // the original selection drag.
294 draggingHandle = null
Zach Klippenstein63870892022-01-14 12:45:18 -0800295 currentDragPosition = null
haoyue6d80a12020-12-02 16:04:52 -0800296 }
haoyu9085c882020-12-08 12:01:06 -0800297
haoyue04245e2021-03-08 14:52:56 -0800298 selectionRegistrar.onSelectableChangeCallback = { selectableKey ->
299 if (selectableKey in selectionRegistrar.subselections) {
haoyu9085c882020-12-08 12:01:06 -0800300 // clear the selection range of each Selectable.
301 onRelease()
302 selection = null
303 }
304 }
haoyue04245e2021-03-08 14:52:56 -0800305
306 selectionRegistrar.afterSelectableUnsubscribe = { selectableKey ->
307 if (
308 selectableKey == selection?.start?.selectableId ||
309 selectableKey == selection?.end?.selectableId
310 ) {
311 // The selectable that contains a selection handle just unsubscribed.
312 // Hide selection handles for now
313 startHandlePosition = null
314 endHandlePosition = null
315 }
316 }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100317 }
318
Zach Klippensteinadabe342021-11-11 16:38:13 -0800319 /**
320 * Returns the [Selectable] responsible for managing the given [AnchorInfo], or null if the
321 * anchor is not from a currently-registered [Selectable].
322 */
323 internal fun getAnchorSelectable(anchor: AnchorInfo): Selectable? {
324 return selectionRegistrar.selectableMap[anchor.selectableId]
Andrey Rudenkod6c91be2021-03-18 15:45:20 +0100325 }
326
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100327 private fun updateHandleOffsets() {
328 val selection = selection
329 val containerCoordinates = containerLayoutCoordinates
Zach Klippensteinadabe342021-11-11 16:38:13 -0800330 val startSelectable = selection?.start?.let(::getAnchorSelectable)
331 val endSelectable = selection?.end?.let(::getAnchorSelectable)
haoyue04245e2021-03-08 14:52:56 -0800332 val startLayoutCoordinates = startSelectable?.getLayoutCoordinates()
333 val endLayoutCoordinates = endSelectable?.getLayoutCoordinates()
haoyue2678c62020-12-09 08:39:12 -0800334 if (
335 selection == null ||
336 containerCoordinates == null ||
337 !containerCoordinates.isAttached ||
338 startLayoutCoordinates == null ||
339 endLayoutCoordinates == null
340 ) {
341 this.startHandlePosition = null
342 this.endHandlePosition = null
343 return
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100344 }
haoyue2678c62020-12-09 08:39:12 -0800345
George Mount77ca2a22020-12-11 17:46:19 +0000346 val startHandlePosition = containerCoordinates.localPositionOf(
haoyue2678c62020-12-09 08:39:12 -0800347 startLayoutCoordinates,
haoyue04245e2021-03-08 14:52:56 -0800348 startSelectable.getHandlePosition(
haoyue2678c62020-12-09 08:39:12 -0800349 selection = selection,
350 isStartHandle = true
351 )
352 )
George Mount77ca2a22020-12-11 17:46:19 +0000353 val endHandlePosition = containerCoordinates.localPositionOf(
haoyue2678c62020-12-09 08:39:12 -0800354 endLayoutCoordinates,
haoyue04245e2021-03-08 14:52:56 -0800355 endSelectable.getHandlePosition(
haoyue2678c62020-12-09 08:39:12 -0800356 selection = selection,
357 isStartHandle = false
358 )
359 )
360
361 val visibleBounds = containerCoordinates.visibleBounds()
Halil Ozercanb2486f12023-01-09 07:40:22 +0000362
363 // set the new handle position only if the handle is in visible bounds or
364 // the handle is still dragging. If handle goes out of visible bounds during drag, handle
365 // popup is also removed from composition, halting the drag gesture. This affects multiple
366 // text selection when selected text is configured with maxLines=1 and overflow=clip.
367 this.startHandlePosition = startHandlePosition.takeIf {
368 visibleBounds.containsInclusive(startHandlePosition) ||
369 draggingHandle == Handle.SelectionStart
370 }
371 this.endHandlePosition = endHandlePosition.takeIf {
372 visibleBounds.containsInclusive(endHandlePosition) ||
373 draggingHandle == Handle.SelectionEnd
374 }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100375 }
376
377 /**
378 * Returns non-nullable [containerLayoutCoordinates].
379 */
380 internal fun requireContainerCoordinates(): LayoutCoordinates {
381 val coordinates = containerLayoutCoordinates
Ralston Da Silvaf24ae422023-05-30 12:59:24 -0700382 requireNotNull(coordinates) { "null coordinates" }
383 require(coordinates.isAttached) { "unattached coordinates" }
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100384 return coordinates
385 }
386
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700387 internal fun selectAll(
388 selectableId: Long,
389 previousSelection: Selection?
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700390 ): Pair<Selection?, Map<Long, Selection>> {
391 val subselections = mutableMapOf<Long, Selection>()
392 val newSelection = selectionRegistrar.sort(requireContainerCoordinates())
393 .fastFold(null) { mergedSelection: Selection?, selectable: Selectable ->
394 val selection = if (selectable.selectableId == selectableId)
395 selectable.getSelectAllSelection() else null
396 selection?.let { subselections[selectable.selectableId] = it }
397 merge(mergedSelection, selection)
398 }
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800399 if (isInTouchMode && newSelection != previousSelection) {
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700400 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
401 }
Qingqing Deng6bc981c2021-05-11 22:43:51 -0700402 return Pair(newSelection, subselections)
403 }
404
Grant Toepferd681b5c2023-06-14 11:24:02 -0700405 /**
406 * Returns whether the start and end anchors are equal.
407 *
408 * It is possible that this returns true, but the selection is still empty because it has
409 * multiple collapsed selections across multiple selectables. To test for that case, use
410 * [isNonEmptySelection].
411 */
412 internal fun isTriviallyCollapsedSelection(): Boolean {
413 val selection = selection ?: return true
414 return selection.start == selection.end
415 }
416
417 /**
418 * Returns whether the selection selects zero characters.
419 *
420 * It is possible that the selection anchors are different but still result in a zero-width
421 * selection. In this case, you may want to still show the selection anchors, but not allow for
422 * a user to try and copy zero characters. To test for whether the anchors are equal, use
423 * [isTriviallyCollapsedSelection].
424 */
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800425 internal fun isNonEmptySelection(): Boolean {
426 val selection = selection ?: return false
Grant Toepferd681b5c2023-06-14 11:24:02 -0700427 if (selection.start == selection.end) {
428 return false
429 }
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800430
431 var betweenSelectables = false
432 selectionRegistrar.sort(requireContainerCoordinates()).fastForEach {
433 if (
434 it.selectableId != selection.start.selectableId &&
435 it.selectableId != selection.end.selectableId &&
436 !betweenSelectables
437 ) {
438 // haven't found our selection yet, continue
439 return@fastForEach
440 }
441
442 betweenSelectables = true
443 if (!isCurrentSelectionEmpty(selection = selection, selectable = it)) {
444 return true
445 }
446
447 // short-circuit if this is the last selectable
448 if (it.selectableId == selection.end.selectableId && !selection.handlesCrossed ||
449 it.selectableId == selection.start.selectableId && selection.handlesCrossed
450 ) return false
451 }
452 return false
453 }
454
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800455 internal fun getSelectedText(): AnnotatedString? {
Andrey Kulikovb8c7e052020-04-15 19:20:09 +0100456 val selectables = selectionRegistrar.sort(requireContainerCoordinates())
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800457 var selectedText: AnnotatedString? = null
458
459 selection?.let {
Louis Pullen-Freilichc6e92aa2021-03-08 00:42:16 +0000460 for (i in selectables.indices) {
haoyue04245e2021-03-08 14:52:56 -0800461 val selectable = selectables[i]
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800462 // Continue if the current selectable is before the selection starts.
haoyue04245e2021-03-08 14:52:56 -0800463 if (selectable.selectableId != it.start.selectableId &&
464 selectable.selectableId != it.end.selectableId &&
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800465 selectedText == null
466 ) continue
467
468 val currentSelectedText = getCurrentSelectedText(
haoyue04245e2021-03-08 14:52:56 -0800469 selectable = selectable,
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800470 selection = it
471 )
472 selectedText = selectedText?.plus(currentSelectedText) ?: currentSelectedText
473
474 // Break if the current selectable is the last selected selectable.
haoyue04245e2021-03-08 14:52:56 -0800475 if (selectable.selectableId == it.end.selectableId && !it.handlesCrossed ||
476 selectable.selectableId == it.start.selectableId && it.handlesCrossed
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800477 ) break
478 }
479 }
480 return selectedText
481 }
482
Qingqing Dengde023cc2020-04-24 14:23:41 -0700483 internal fun copy() {
Qingqing Dengf2d0a2d2020-03-12 14:19:50 -0700484 val selectedText = getSelectedText()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800485 selectedText?.takeIf { it.isNotEmpty() }?.let { clipboardManager?.setText(it) }
Qingqing Dengde023cc2020-04-24 14:23:41 -0700486 }
487
488 /**
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800489 * Whether toolbar should be shown right now.
490 * Examples: Show toolbar after user finishes selection.
491 * Hide it during selection.
492 * Hide it when no selection exists.
493 */
494 internal var showToolbar = false
495 internal set(value) {
496 field = value
497 if (value && isInTouchMode) showSelectionToolbar() else hideSelectionToolbar()
498 }
499
500 /**
Qingqing Dengde023cc2020-04-24 14:23:41 -0700501 * This function get the selected region as a Rectangle region, and pass it to [TextToolbar]
502 * to make the FloatingToolbar show up in the proper place. In addition, this function passes
503 * the copy method as a callback when "copy" is clicked.
504 */
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800505 private fun showSelectionToolbar() {
506 if (hasFocus && isNonEmptySelection()) {
507 textToolbar?.showMenu(
508 getContentRect(),
509 onCopyRequested = {
510 copy()
511 onRelease()
512 }
513 )
Qingqing Denga89450d2020-04-03 19:11:49 -0700514 }
Qingqing Dengf2d0a2d2020-03-12 14:19:50 -0700515 }
516
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800517 private fun hideSelectionToolbar() {
haoyu3c3fb452021-02-18 01:01:14 -0800518 if (hasFocus && textToolbar?.status == TextToolbarStatus.Shown) {
haoyue6d80a12020-12-02 16:04:52 -0800519 textToolbar?.hide()
Qingqing Dengde023cc2020-04-24 14:23:41 -0700520 }
521 }
522
523 private fun updateSelectionToolbarPosition() {
haoyu3c3fb452021-02-18 01:01:14 -0800524 if (hasFocus && textToolbar?.status == TextToolbarStatus.Shown) {
Qingqing Dengde023cc2020-04-24 14:23:41 -0700525 showSelectionToolbar()
526 }
527 }
528
529 /**
530 * Calculate selected region as [Rect]. The top is the top of the first selected
531 * line, and the bottom is the bottom of the last selected line. The left is the leftmost
532 * handle's horizontal coordinates, and the right is the rightmost handle's coordinates.
533 */
534 private fun getContentRect(): Rect {
Nader Jawad8432b8d2020-07-27 14:51:23 -0700535 val selection = selection ?: return Rect.Zero
Zach Klippensteinadabe342021-11-11 16:38:13 -0800536 val startSelectable = getAnchorSelectable(selection.start)
537 val endSelectable = getAnchorSelectable(selection.end)
haoyue04245e2021-03-08 14:52:56 -0800538 val startLayoutCoordinates = startSelectable?.getLayoutCoordinates() ?: return Rect.Zero
539 val endLayoutCoordinates = endSelectable?.getLayoutCoordinates() ?: return Rect.Zero
Qingqing Dengde023cc2020-04-24 14:23:41 -0700540
541 val localLayoutCoordinates = containerLayoutCoordinates
542 if (localLayoutCoordinates != null && localLayoutCoordinates.isAttached) {
George Mount77ca2a22020-12-11 17:46:19 +0000543 var startOffset = localLayoutCoordinates.localPositionOf(
Qingqing Dengde023cc2020-04-24 14:23:41 -0700544 startLayoutCoordinates,
haoyue04245e2021-03-08 14:52:56 -0800545 startSelectable.getHandlePosition(
Qingqing Dengde023cc2020-04-24 14:23:41 -0700546 selection = selection,
547 isStartHandle = true
548 )
549 )
George Mount77ca2a22020-12-11 17:46:19 +0000550 var endOffset = localLayoutCoordinates.localPositionOf(
Qingqing Dengde023cc2020-04-24 14:23:41 -0700551 endLayoutCoordinates,
haoyue04245e2021-03-08 14:52:56 -0800552 endSelectable.getHandlePosition(
Qingqing Dengde023cc2020-04-24 14:23:41 -0700553 selection = selection,
554 isStartHandle = false
555 )
556 )
557
558 startOffset = localLayoutCoordinates.localToRoot(startOffset)
559 endOffset = localLayoutCoordinates.localToRoot(endOffset)
560
561 val left = min(startOffset.x, endOffset.x)
562 val right = max(startOffset.x, endOffset.x)
563
George Mount77ca2a22020-12-11 17:46:19 +0000564 var startTop = localLayoutCoordinates.localPositionOf(
Qingqing Dengde023cc2020-04-24 14:23:41 -0700565 startLayoutCoordinates,
Nader Jawad6df06122020-06-03 15:27:08 -0700566 Offset(
Nader Jawade6a9b332020-05-21 13:49:20 -0700567 0f,
haoyue04245e2021-03-08 14:52:56 -0800568 startSelectable.getBoundingBox(selection.start.offset).top
Qingqing Dengde023cc2020-04-24 14:23:41 -0700569 )
570 )
571
George Mount77ca2a22020-12-11 17:46:19 +0000572 var endTop = localLayoutCoordinates.localPositionOf(
Qingqing Dengde023cc2020-04-24 14:23:41 -0700573 endLayoutCoordinates,
Nader Jawad6df06122020-06-03 15:27:08 -0700574 Offset(
Nader Jawade6a9b332020-05-21 13:49:20 -0700575 0.0f,
haoyue04245e2021-03-08 14:52:56 -0800576 endSelectable.getBoundingBox(selection.end.offset).top
Qingqing Dengde023cc2020-04-24 14:23:41 -0700577 )
578 )
579
580 startTop = localLayoutCoordinates.localToRoot(startTop)
581 endTop = localLayoutCoordinates.localToRoot(endTop)
582
583 val top = min(startTop.y, endTop.y)
Haoyu Zhangd47f1fe2021-07-13 03:10:55 -0700584 val bottom = max(startOffset.y, endOffset.y) + (HandleHeight.value * 4.0).toFloat()
Qingqing Dengde023cc2020-04-24 14:23:41 -0700585
586 return Rect(
Nader Jawade6a9b332020-05-21 13:49:20 -0700587 left,
588 top,
589 right,
590 bottom
Qingqing Dengde023cc2020-04-24 14:23:41 -0700591 )
592 }
Nader Jawad8432b8d2020-07-27 14:51:23 -0700593 return Rect.Zero
Qingqing Dengde023cc2020-04-24 14:23:41 -0700594 }
595
Qingqing Dengb6f5d8a2019-11-11 18:19:22 -0800596 // This is for PressGestureDetector to cancel the selection.
597 fun onRelease() {
haoyue04245e2021-03-08 14:52:56 -0800598 selectionRegistrar.subselections = emptyMap()
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800599 showToolbar = false
haoyue04245e2021-03-08 14:52:56 -0800600 if (selection != null) {
601 onSelectionChange(null)
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800602 if (isInTouchMode) {
603 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
604 }
haoyue04245e2021-03-08 14:52:56 -0800605 }
Qingqing Dengb6f5d8a2019-11-11 18:19:22 -0800606 }
607
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800608 fun handleDragObserver(isStartHandle: Boolean): TextDragObserver = object : TextDragObserver {
609 override fun onDown(point: Offset) {
Zach Klippenstein63870892022-01-14 12:45:18 -0800610 val selection = selection ?: return
611 val anchor = if (isStartHandle) selection.start else selection.end
612 val selectable = getAnchorSelectable(anchor) ?: return
613 // The LayoutCoordinates of the composable where the drag gesture should begin. This
614 // is used to convert the position of the beginning of the drag gesture from the
615 // composable coordinates to selection container coordinates.
616 val beginLayoutCoordinates = selectable.getLayoutCoordinates() ?: return
617
618 // The position of the character where the drag gesture should begin. This is in
619 // the composable coordinates.
620 val beginCoordinates = getAdjustedCoordinates(
621 selectable.getHandlePosition(
622 selection = selection, isStartHandle = isStartHandle
623 )
624 )
625
626 // Convert the position where drag gesture begins from composable coordinates to
627 // selection container coordinates.
628 currentDragPosition = requireContainerCoordinates().localPositionOf(
629 beginLayoutCoordinates,
630 beginCoordinates
631 )
632 draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800633 showToolbar = false
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800634 }
635
636 override fun onStart(startPoint: Offset) {
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800637 val selection = selection!!
638 val startSelectable =
639 selectionRegistrar.selectableMap[selection.start.selectableId]
640 val endSelectable =
641 selectionRegistrar.selectableMap[selection.end.selectableId]
642 // The LayoutCoordinates of the composable where the drag gesture should begin. This
643 // is used to convert the position of the beginning of the drag gesture from the
644 // composable coordinates to selection container coordinates.
645 val beginLayoutCoordinates = if (isStartHandle) {
646 startSelectable?.getLayoutCoordinates()!!
647 } else {
648 endSelectable?.getLayoutCoordinates()!!
649 }
650
651 // The position of the character where the drag gesture should begin. This is in
652 // the composable coordinates.
653 val beginCoordinates = getAdjustedCoordinates(
654 if (isStartHandle) {
655 startSelectable!!.getHandlePosition(
656 selection = selection, isStartHandle = true
657 )
Siyamed Sinir472c3162019-10-21 23:41:00 -0700658 } else {
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800659 endSelectable!!.getHandlePosition(
660 selection = selection, isStartHandle = false
661 )
Siyamed Sinir472c3162019-10-21 23:41:00 -0700662 }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800663 )
Siyamed Sinir472c3162019-10-21 23:41:00 -0700664
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800665 // Convert the position where drag gesture begins from composable coordinates to
666 // selection container coordinates.
667 dragBeginPosition = requireContainerCoordinates().localPositionOf(
668 beginLayoutCoordinates,
669 beginCoordinates
670 )
Siyamed Sinir472c3162019-10-21 23:41:00 -0700671
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800672 // Zero out the total distance that being dragged.
673 dragTotalDistance = Offset.Zero
674 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700675
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800676 override fun onDrag(delta: Offset) {
677 dragTotalDistance += delta
678 val endPosition = dragBeginPosition + dragTotalDistance
679 val consumed = updateSelection(
680 newPosition = endPosition,
681 previousPosition = dragBeginPosition,
682 isStartHandle = isStartHandle,
683 adjustment = SelectionAdjustment.CharacterWithWordAccelerate
684 )
685 if (consumed) {
686 dragBeginPosition = endPosition
Nader Jawad6df06122020-06-03 15:27:08 -0700687 dragTotalDistance = Offset.Zero
Qingqing Deng6f56a912019-05-13 10:10:37 -0700688 }
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800689 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700690
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800691 private fun done() {
692 showToolbar = true
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800693 draggingHandle = null
Zach Klippenstein63870892022-01-14 12:45:18 -0800694 currentDragPosition = null
Zach Klippenstein72e3e512022-01-14 12:24:09 -0800695 }
haoyue6d80a12020-12-02 16:04:52 -0800696
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800697 override fun onUp() = done()
698 override fun onStop() = done()
699 override fun onCancel() = done()
Qingqing Deng6f56a912019-05-13 10:10:37 -0700700 }
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700701
Ralston Da Silvade62bc62021-06-02 17:46:44 -0700702 /**
703 * Detect tap without consuming the up event.
704 */
705 private suspend fun PointerInputScope.detectNonConsumingTap(onTap: (Offset) -> Unit) {
George Mount32de9dd2022-10-05 14:51:06 -0700706 awaitEachGesture {
707 waitForUpOrCancellation()?.let {
708 onTap(it.position)
Ralston Da Silvade62bc62021-06-02 17:46:44 -0700709 }
710 }
711 }
712
713 private fun Modifier.onClearSelectionRequested(block: () -> Unit): Modifier {
714 return if (hasFocus) pointerInput(Unit) { detectNonConsumingTap { block() } } else this
715 }
716
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700717 private fun convertToContainerCoordinates(
718 layoutCoordinates: LayoutCoordinates,
719 offset: Offset
Grant Toepferd681b5c2023-06-14 11:24:02 -0700720 ): Offset {
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700721 val coordinates = containerLayoutCoordinates
Grant Toepferd681b5c2023-06-14 11:24:02 -0700722 if (coordinates == null || !coordinates.isAttached) return Offset.Unspecified
George Mount77ca2a22020-12-11 17:46:19 +0000723 return requireContainerCoordinates().localPositionOf(layoutCoordinates, offset)
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700724 }
725
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700726 /**
727 * Cancel the previous selection and start a new selection at the given [position].
728 * It's used for long-press, double-click, triple-click and so on to start selection.
729 *
730 * @param position initial position of the selection. Both start and end handle is considered
731 * at this position.
732 * @param isStartHandle whether it's considered as the start handle moving. This parameter
733 * will influence the [SelectionAdjustment]'s behavior. For example,
734 * [SelectionAdjustment.Character] only adjust the moving handle.
735 * @param adjustment the selection adjustment.
736 */
737 private fun startSelection(
738 position: Offset,
739 isStartHandle: Boolean,
740 adjustment: SelectionAdjustment
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700741 ) {
Grant Toepferd681b5c2023-06-14 11:24:02 -0700742 previousSelectionLayout = null
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700743 updateSelection(
744 startHandlePosition = position,
745 endHandlePosition = position,
Grant Toepferd681b5c2023-06-14 11:24:02 -0700746 previousHandlePosition = Offset.Unspecified,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700747 isStartHandle = isStartHandle,
748 adjustment = adjustment
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700749 )
Qingqing Deng739ec3a2020-09-09 15:40:30 -0700750 }
Haoyu Zhanga78608e2021-08-03 17:10:18 -0700751
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700752 /**
753 * Updates the selection after one of the selection handle moved.
754 *
755 * @param newPosition the new position of the moving selection handle.
756 * @param previousPosition the previous position of the moving selection handle.
757 * @param isStartHandle whether the moving selection handle is the start handle.
758 * @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
759 * produce the final selection range.
760 *
761 * @return a boolean representing whether the movement is consumed.
762 *
763 * @see SelectionAdjustment
764 */
765 internal fun updateSelection(
766 newPosition: Offset?,
Grant Toepferd681b5c2023-06-14 11:24:02 -0700767 previousPosition: Offset,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700768 isStartHandle: Boolean,
769 adjustment: SelectionAdjustment,
770 ): Boolean {
771 if (newPosition == null) return false
772 val otherHandlePosition = selection?.let { selection ->
773 val otherSelectableId = if (isStartHandle) {
774 selection.end.selectableId
775 } else {
776 selection.start.selectableId
777 }
778 val otherSelectable =
779 selectionRegistrar.selectableMap[otherSelectableId] ?: return@let null
780 convertToContainerCoordinates(
781 otherSelectable.getLayoutCoordinates()!!,
782 getAdjustedCoordinates(
783 otherSelectable.getHandlePosition(selection, !isStartHandle)
784 )
Haoyu Zhanga78608e2021-08-03 17:10:18 -0700785 )
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700786 } ?: return false
787
788 val startHandlePosition = if (isStartHandle) newPosition else otherHandlePosition
789 val endHandlePosition = if (isStartHandle) otherHandlePosition else newPosition
790
791 return updateSelection(
792 startHandlePosition = startHandlePosition,
793 endHandlePosition = endHandlePosition,
794 previousHandlePosition = previousPosition,
795 isStartHandle = isStartHandle,
796 adjustment = adjustment
797 )
798 }
799
800 /**
801 * Updates the selection after one of the selection handle moved.
802 *
803 * To make sure that [SelectionAdjustment] works correctly, it's expected that only one
804 * selection handle is updated each time. The only exception is that when a new selection is
805 * started. In this case, [previousHandlePosition] is always null.
806 *
807 * @param startHandlePosition the position of the start selection handle.
808 * @param endHandlePosition the position of the end selection handle.
809 * @param previousHandlePosition the position of the moving handle before the update.
810 * @param isStartHandle whether the moving selection handle is the start handle.
811 * @param adjustment the [SelectionAdjustment] used to adjust the raw selection range and
812 * produce the final selection range.
813 *
814 * @return a boolean representing whether the movement is consumed. It's useful for the case
815 * where a selection handle is updating consecutively. When the return value is true, it's
816 * expected that the caller will update the [startHandlePosition] to be the given
817 * [endHandlePosition] in following calls.
818 *
819 * @see SelectionAdjustment
820 */
821 internal fun updateSelection(
822 startHandlePosition: Offset,
823 endHandlePosition: Offset,
Grant Toepferd681b5c2023-06-14 11:24:02 -0700824 previousHandlePosition: Offset,
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700825 isStartHandle: Boolean,
826 adjustment: SelectionAdjustment,
827 ): Boolean {
Zach Klippensteinadabe342021-11-11 16:38:13 -0800828 draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
Zach Klippenstein63870892022-01-14 12:45:18 -0800829 currentDragPosition = if (isStartHandle) startHandlePosition else endHandlePosition
Haoyu Zhang0020dd72021-08-19 19:21:03 -0700830
Grant Toepferd681b5c2023-06-14 11:24:02 -0700831 val selectionLayout = getSelectionLayout(
832 startHandlePosition = startHandlePosition,
833 endHandlePosition = endHandlePosition,
834 previousHandlePosition = previousHandlePosition,
835 isStartHandle = isStartHandle,
836 )
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800837
Grant Toepferd681b5c2023-06-14 11:24:02 -0700838 if (!selectionLayout.shouldRecomputeSelection(previousSelectionLayout)) {
839 return false
Haoyu Zhanga78608e2021-08-03 17:10:18 -0700840 }
Grant Toepferd681b5c2023-06-14 11:24:02 -0700841
842 val newSelection = adjustment.adjust(selectionLayout)
843 if (newSelection != selection) {
844 selectionChanged(selectionLayout, newSelection)
845 }
846 previousSelectionLayout = selectionLayout
847 return true
Haoyu Zhanga78608e2021-08-03 17:10:18 -0700848 }
Andrey Rudenkof3906a02021-10-12 13:23:40 +0200849
Grant Toepferd681b5c2023-06-14 11:24:02 -0700850 private fun getSelectionLayout(
851 startHandlePosition: Offset,
852 endHandlePosition: Offset,
853 previousHandlePosition: Offset,
854 isStartHandle: Boolean,
855 ): SelectionLayout {
856 val containerCoordinates = requireContainerCoordinates()
857 val sortedSelectables = selectionRegistrar.sort(containerCoordinates)
858
859 val idToIndexMap = mutableMapOf<Long, Int>()
860 sortedSelectables.fastForEachIndexed { index, selectable ->
861 idToIndexMap[selectable.selectableId] = index
862 }
863
864 val selectableIdOrderingComparator = compareBy<Long> { idToIndexMap[it] }
865
866 // if previous handle is null, then treat this as a new selection.
867 val previousSelection = if (previousHandlePosition.isUnspecified) null else selection
868 val builder = SelectionLayoutBuilder(
869 startHandlePosition = startHandlePosition,
870 endHandlePosition = endHandlePosition,
871 previousHandlePosition = previousHandlePosition,
872 containerCoordinates = containerCoordinates,
873 isStartHandle = isStartHandle,
874 previousSelection = previousSelection,
875 selectableIdOrderingComparator = selectableIdOrderingComparator,
876 )
877
878 sortedSelectables.fastForEach {
879 it.appendSelectableInfoToBuilder(builder)
880 }
881
882 return builder.build()
883 }
884
885 private fun selectionChanged(selectionLayout: SelectionLayout, newSelection: Selection) {
886 if (shouldPerformHaptics()) {
887 hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
888 }
889 selectionRegistrar.subselections = selectionLayout.createSubSelections(newSelection)
890 onSelectionChange(newSelection)
891 }
892
893 @VisibleForTesting
894 internal fun shouldPerformHaptics(): Boolean =
895 isInTouchMode && selectionRegistrar.selectables.fastAny { it.getText().isNotEmpty() }
896
Andrey Rudenkof3906a02021-10-12 13:23:40 +0200897 fun contextMenuOpenAdjustment(position: Offset) {
898 val isEmptySelection = selection?.toTextRange()?.collapsed ?: true
899 // TODO(b/209483184) the logic should be more complex here, it should check that current
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800900 // selection doesn't include click position
Andrey Rudenkof3906a02021-10-12 13:23:40 +0200901 if (isEmptySelection) {
902 startSelection(
903 position = position,
904 isStartHandle = true,
905 adjustment = SelectionAdjustment.Word
906 )
907 }
908 }
Qingqing Deng6f56a912019-05-13 10:10:37 -0700909}
910
Andrey Rudenko5dc7aad2020-09-18 10:33:37 +0200911internal fun merge(lhs: Selection?, rhs: Selection?): Selection? {
Siyamed Sinir700df8452019-10-22 20:23:58 -0700912 return lhs?.merge(rhs) ?: rhs
913}
Qingqing Deng648e1bf2019-12-30 11:49:48 -0800914
Andrey Rudenko8b4981e2021-01-29 15:31:59 +0100915internal expect fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean
916
Zach Klippensteinadabe342021-11-11 16:38:13 -0800917internal expect fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier
918
Zach Klippenstein9c32bdf2021-11-11 16:38:13 -0800919internal fun calculateSelectionMagnifierCenterAndroid(
920 manager: SelectionManager,
921 magnifierSize: IntSize
922): Offset {
Zach Klippensteinadabe342021-11-11 16:38:13 -0800923 val selection = manager.selection ?: return Offset.Unspecified
924 return when (manager.draggingHandle) {
925 null -> return Offset.Unspecified
Grant Toepferd681b5c2023-06-14 11:24:02 -0700926 Handle.SelectionStart -> getMagnifierCenter(manager, magnifierSize, selection.start)
927 Handle.SelectionEnd -> getMagnifierCenter(manager, magnifierSize, selection.end)
Zach Klippensteinadabe342021-11-11 16:38:13 -0800928 Handle.Cursor -> error("SelectionContainer does not support cursor")
929 }
930}
931
Grant Toepferd681b5c2023-06-14 11:24:02 -0700932private fun getMagnifierCenter(
933 manager: SelectionManager,
934 magnifierSize: IntSize,
935 anchor: AnchorInfo
936): Offset {
937 val selectable = manager.getAnchorSelectable(anchor) ?: return Offset.Unspecified
938 val containerCoordinates = manager.containerLayoutCoordinates ?: return Offset.Unspecified
939 val selectableCoordinates = selectable.getLayoutCoordinates() ?: return Offset.Unspecified
940 val offset = anchor.offset
941
942 if (offset > selectable.getLastVisibleOffset()) return Offset.Unspecified
943
944 // The horizontal position doesn't snap to cursor positions but should directly track the
945 // actual drag.
946 val localDragPosition = selectableCoordinates.localPositionOf(
947 containerCoordinates,
948 manager.currentDragPosition!!
949 )
950 val dragX = localDragPosition.x
951
952 // But it is constrained by the horizontal bounds of the current line.
953 val lineRange = selectable.getRangeOfLineContaining(offset)
954 val textConstrainedX = if (lineRange.collapsed) {
955 // A collapsed range implies the text is empty.
956 // line left and right are equal for this offset, so use either
957 selectable.getLineLeft(offset)
958 } else {
959 val lineStartX = selectable.getLineLeft(lineRange.start)
960 val lineEndX = selectable.getLineRight(lineRange.end - 1)
961 // in RTL/BiDi, lineStartX may be larger than lineEndX
962 val minX = minOf(lineStartX, lineEndX)
963 val maxX = maxOf(lineStartX, lineEndX)
964 dragX.coerceIn(minX, maxX)
965 }
966
967 // selectable couldn't determine horizontals
968 if (textConstrainedX == -1f) return Offset.Unspecified
969
970 // Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
971 // magnifier actually is). See
972 // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
973 if ((dragX - textConstrainedX).absoluteValue > magnifierSize.width / 2) {
974 return Offset.Unspecified
975 }
976
977 val lineCenterY = selectable.getCenterYForOffset(offset)
978
979 // selectable couldn't determine the line center
980 if (lineCenterY == -1f) return Offset.Unspecified
981
982 return containerCoordinates.localPositionOf(
983 sourceCoordinates = selectableCoordinates,
984 relativeToSource = Offset(textConstrainedX, lineCenterY)
985 )
986}
987
Grant Toepferfefbd7a2023-03-03 15:02:55 -0800988private fun isCurrentSelectionEmpty(
989 selectable: Selectable,
990 selection: Selection
991): Boolean {
992 val selectableId = selectable.selectableId
993 val startSelectableId = selection.start.selectableId
994 val endSelectableId = selection.end.selectableId
995
996 if (selectableId == startSelectableId && selectableId == endSelectableId) {
997 return selection.start.offset == selection.end.offset
998 }
999
1000 val text = selectable.getText()
1001 val handlesCrossed = selection.handlesCrossed
1002 return when (selectableId) {
1003 startSelectableId -> selection.start.offset == if (handlesCrossed) 0 else text.length
1004 endSelectableId -> selection.end.offset == if (handlesCrossed) text.length else 0
1005 else -> text.isEmpty()
1006 }
1007}
1008
Andrey Rudenko5dc7aad2020-09-18 10:33:37 +02001009internal fun getCurrentSelectedText(
Qingqing Deng648e1bf2019-12-30 11:49:48 -08001010 selectable: Selectable,
1011 selection: Selection
1012): AnnotatedString {
1013 val currentText = selectable.getText()
1014
1015 return if (
haoyue04245e2021-03-08 14:52:56 -08001016 selectable.selectableId != selection.start.selectableId &&
1017 selectable.selectableId != selection.end.selectableId
Qingqing Deng648e1bf2019-12-30 11:49:48 -08001018 ) {
1019 // Select the full text content if the current selectable is between the
1020 // start and the end selectables.
1021 currentText
1022 } else if (
haoyue04245e2021-03-08 14:52:56 -08001023 selectable.selectableId == selection.start.selectableId &&
1024 selectable.selectableId == selection.end.selectableId
Qingqing Deng648e1bf2019-12-30 11:49:48 -08001025 ) {
1026 // Select partial text content if the current selectable is the start and
1027 // the end selectable.
1028 if (selection.handlesCrossed) {
1029 currentText.subSequence(selection.end.offset, selection.start.offset)
1030 } else {
1031 currentText.subSequence(selection.start.offset, selection.end.offset)
1032 }
haoyue04245e2021-03-08 14:52:56 -08001033 } else if (selectable.selectableId == selection.start.selectableId) {
Qingqing Deng648e1bf2019-12-30 11:49:48 -08001034 // Select partial text content if the current selectable is the start
1035 // selectable.
1036 if (selection.handlesCrossed) {
1037 currentText.subSequence(0, selection.start.offset)
1038 } else {
1039 currentText.subSequence(selection.start.offset, currentText.length)
1040 }
1041 } else {
1042 // Selectable partial text content if the current selectable is the end
1043 // selectable.
1044 if (selection.handlesCrossed) {
1045 currentText.subSequence(selection.end.offset, currentText.length)
1046 } else {
1047 currentText.subSequence(0, selection.end.offset)
1048 }
1049 }
1050}
haoyue2678c62020-12-09 08:39:12 -08001051
1052/** Returns the boundary of the visible area in this [LayoutCoordinates]. */
haoyuac341f02021-01-22 22:01:56 -08001053internal fun LayoutCoordinates.visibleBounds(): Rect {
haoyue2678c62020-12-09 08:39:12 -08001054 // globalBounds is the global boundaries of this LayoutCoordinates after it's clipped by
1055 // parents. We can think it as the global visible bounds of this Layout. Here globalBounds
1056 // is convert to local, which is the boundary of the visible area within the LayoutCoordinates.
George Mount77ca2a22020-12-11 17:46:19 +00001057 val boundsInWindow = boundsInWindow()
haoyue2678c62020-12-09 08:39:12 -08001058 return Rect(
George Mount77ca2a22020-12-11 17:46:19 +00001059 windowToLocal(boundsInWindow.topLeft),
1060 windowToLocal(boundsInWindow.bottomRight)
haoyue2678c62020-12-09 08:39:12 -08001061 )
1062}
1063
haoyuac341f02021-01-22 22:01:56 -08001064internal fun Rect.containsInclusive(offset: Offset): Boolean =
haoyue2678c62020-12-09 08:39:12 -08001065 offset.x in left..right && offset.y in top..bottom