1 /*
<lambda>null2 * Copyright 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.foundation.text.selection
18
19 import androidx.compose.foundation.gestures.awaitEachGesture
20 import androidx.compose.foundation.gestures.awaitLongPressOrCancellation
21 import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation
22 import androidx.compose.foundation.gestures.drag
23 import androidx.compose.foundation.gestures.pointerSlop
24 import androidx.compose.foundation.text.TextDragObserver
25 import androidx.compose.ui.Modifier
26 import androidx.compose.ui.geometry.Offset
27 import androidx.compose.ui.geometry.isSpecified
28 import androidx.compose.ui.input.pointer.AwaitPointerEventScope
29 import androidx.compose.ui.input.pointer.PointerEvent
30 import androidx.compose.ui.input.pointer.PointerEventPass
31 import androidx.compose.ui.input.pointer.PointerInputChange
32 import androidx.compose.ui.input.pointer.PointerInputScope
33 import androidx.compose.ui.input.pointer.PointerType
34 import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
35 import androidx.compose.ui.input.pointer.changedToUp
36 import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
37 import androidx.compose.ui.input.pointer.isPrimaryPressed
38 import androidx.compose.ui.input.pointer.pointerInput
39 import androidx.compose.ui.input.pointer.positionChange
40 import androidx.compose.ui.platform.ViewConfiguration
41 import androidx.compose.ui.util.fastAll
42 import androidx.compose.ui.util.fastForEach
43 import kotlinx.coroutines.CancellationException
44
45 /**
46 * Without shift it starts the new selection from scratch. With shift it expands/shrinks existing
47 * selection. A click sets the start and end of the selection, but shift click only sets the end of
48 * the selection.
49 */
50 internal interface MouseSelectionObserver {
51 /**
52 * Invoked on click (with shift).
53 *
54 * @return if event will be consumed
55 */
56 fun onExtend(downPosition: Offset): Boolean
57
58 /**
59 * Invoked on drag after shift click.
60 *
61 * @return if event will be consumed
62 */
63 fun onExtendDrag(dragPosition: Offset): Boolean
64
65 /**
66 * Invoked on first click (without shift).
67 *
68 * @return if event will be consumed
69 */
70 // if returns true event will be consumed
71 fun onStart(downPosition: Offset, adjustment: SelectionAdjustment): Boolean
72
73 /**
74 * Invoked when dragging (without shift).
75 *
76 * @return if event will be consumed
77 */
78 fun onDrag(dragPosition: Offset, adjustment: SelectionAdjustment): Boolean
79
80 /** Invoked when finishing a selection mouse gesture. */
81 fun onDragDone()
82 }
83
84 // TODO(b/281584353) This is a stand in for updating the state in some global way.
85 // For example, any touch/click in compose should change touch mode.
86 // This only updates when the pointer is within the bounds of what it is modifying,
87 // thus it is a placeholder until the other functionality is implemented.
88 private const val STATIC_KEY = 867_5309 // unique key to not clash with other global pointer inputs
89
updateSelectionTouchModenull90 internal fun Modifier.updateSelectionTouchMode(updateTouchMode: (Boolean) -> Unit): Modifier =
91 this.pointerInput(STATIC_KEY) {
92 awaitPointerEventScope {
93 while (true) {
94 val event = awaitPointerEvent(PointerEventPass.Initial)
95 updateTouchMode(!event.isPrecisePointer)
96 }
97 }
98 }
99
selectionGestureInputnull100 internal fun Modifier.selectionGestureInput(
101 mouseSelectionObserver: MouseSelectionObserver,
102 textDragObserver: TextDragObserver,
103 ) =
104 this.pointerInput(mouseSelectionObserver, textDragObserver) {
105 val clicksCounter = ClicksCounter(viewConfiguration)
106 awaitEachGesture {
107 val down = awaitDown()
108 if (
109 down.isPrecisePointer &&
110 down.buttons.isPrimaryPressed &&
111 down.changes.fastAll { !it.isConsumed }
112 ) {
113 mouseSelection(mouseSelectionObserver, clicksCounter, down)
114 } else if (!down.isPrecisePointer) {
115 touchSelection(textDragObserver, down)
116 }
117 }
118 }
119
touchSelectionnull120 private suspend fun AwaitPointerEventScope.touchSelection(
121 observer: TextDragObserver,
122 down: PointerEvent
123 ) {
124 try {
125 val firstDown = down.changes.first()
126 val drag = awaitLongPressOrCancellation(firstDown.id)
127 if (drag != null && distanceIsTolerable(viewConfiguration, firstDown, drag)) {
128 observer.onStart(drag.position)
129 if (
130 drag(drag.id) {
131 observer.onDrag(it.positionChange())
132 it.consume()
133 }
134 ) {
135 // consume up if we quit drag gracefully with the up
136 currentEvent.changes.fastForEach { if (it.changedToUp()) it.consume() }
137 observer.onStop()
138 } else {
139 observer.onCancel()
140 }
141 }
142 } catch (c: CancellationException) {
143 observer.onCancel()
144 throw c
145 }
146 }
147
mouseSelectionnull148 private suspend fun AwaitPointerEventScope.mouseSelection(
149 observer: MouseSelectionObserver,
150 clicksCounter: ClicksCounter,
151 down: PointerEvent
152 ) {
153 clicksCounter.update(down)
154 val downChange = down.changes[0]
155 if (down.isShiftPressed) {
156 val started = observer.onExtend(downChange.position)
157 if (started) {
158 val shouldConsumeUp =
159 drag(downChange.id) {
160 if (observer.onExtendDrag(it.position)) {
161 it.consume()
162 }
163 }
164
165 if (shouldConsumeUp) {
166 currentEvent.changes.fastForEach { if (it.changedToUp()) it.consume() }
167 }
168
169 observer.onDragDone()
170 }
171 } else {
172 val selectionAdjustment =
173 when (clicksCounter.clicks) {
174 1 -> SelectionAdjustment.None
175 2 -> SelectionAdjustment.Word
176 else -> SelectionAdjustment.Paragraph
177 }
178
179 val started = observer.onStart(downChange.position, selectionAdjustment)
180 if (started) {
181 var dragConsumed = selectionAdjustment != SelectionAdjustment.None
182 val shouldConsumeUp =
183 drag(downChange.id) {
184 if (observer.onDrag(it.position, selectionAdjustment)) {
185 it.consume()
186 dragConsumed = true
187 }
188 }
189
190 if (shouldConsumeUp && dragConsumed) {
191 currentEvent.changes.fastForEach { if (it.changedToUp()) it.consume() }
192 }
193
194 observer.onDragDone()
195 }
196 }
197 }
198
199 /**
200 * Gesture handler for mouse and touch. Determines whether this is mouse or touch based on the first
201 * down, then uses the gesture handler for that input type, delegating to the appropriate observer.
202 */
selectionGesturePointerInputBtf2null203 internal suspend fun PointerInputScope.selectionGesturePointerInputBtf2(
204 mouseSelectionObserver: MouseSelectionObserver,
205 textDragObserver: TextDragObserver,
206 ) {
207 val clicksCounter = ClicksCounter(viewConfiguration)
208 awaitEachGesture {
209 val downEvent = awaitDown()
210 clicksCounter.update(downEvent)
211 val isPrecise = downEvent.isPrecisePointer
212 if (
213 isPrecise &&
214 downEvent.buttons.isPrimaryPressed &&
215 downEvent.changes.fastAll { !it.isConsumed }
216 ) {
217 mouseSelectionBtf2(mouseSelectionObserver, clicksCounter, downEvent)
218 } else if (!isPrecise) {
219 when (clicksCounter.clicks) {
220 1 -> touchSelectionFirstPress(textDragObserver, downEvent)
221 else -> touchSelectionSubsequentPress(textDragObserver, downEvent)
222 }
223 }
224 }
225 }
226
227 /**
228 * Gesture handler for touch selection on only the first press. The first press will wait for a long
229 * press instead of immediately looking for drags. If no long press is found, this does not trigger
230 * any observer.
231 */
touchSelectionFirstPressnull232 private suspend fun AwaitPointerEventScope.touchSelectionFirstPress(
233 observer: TextDragObserver,
234 downEvent: PointerEvent
235 ) {
236 try {
237 val firstDown = downEvent.changes.first()
238 val longPress = awaitLongPressOrCancellation(firstDown.id)
239 if (longPress != null && distanceIsTolerable(viewConfiguration, firstDown, longPress)) {
240 observer.onStart(longPress.position)
241 val dragCompletedWithUp =
242 drag(longPress.id) {
243 observer.onDrag(it.positionChange())
244 it.consume()
245 }
246 if (dragCompletedWithUp) {
247 // consume up if we quit drag gracefully with the up
248 currentEvent.changes.fastForEach { if (it.changedToUp()) it.consume() }
249 observer.onStop()
250 } else {
251 observer.onCancel()
252 }
253 }
254 } catch (c: CancellationException) {
255 observer.onCancel()
256 throw c
257 }
258 }
259
260 private enum class DownResolution {
261 Up,
262 Drag,
263 Timeout,
264 Cancel
265 }
266
267 /**
268 * Gesture handler for touch selection on all presses except for the first. Subsequent presses
269 * immediately starts looking for drags when the press is received.
270 */
touchSelectionSubsequentPressnull271 private suspend fun AwaitPointerEventScope.touchSelectionSubsequentPress(
272 observer: TextDragObserver,
273 downEvent: PointerEvent
274 ) {
275 try {
276 val firstDown = downEvent.changes.first()
277 val pointerId = firstDown.id
278
279 var overSlop: Offset = Offset.Unspecified
280 val downResolution =
281 withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
282 val firstDragPastSlop =
283 awaitTouchSlopOrCancellation(pointerId) { change, slop ->
284 change.consume()
285 overSlop = slop
286 }
287
288 // If slop is passed, we have started a drag.
289 if (firstDragPastSlop != null && overSlop.isSpecified) {
290 return@withTimeoutOrNull DownResolution.Drag
291 }
292
293 // Otherwise, this either was cancelled or the pointer is now up.
294 val currentChange = currentEvent.changes.first()
295 return@withTimeoutOrNull if (currentChange.changedToUpIgnoreConsumed()) {
296 currentChange.consume()
297 DownResolution.Up
298 } else {
299 DownResolution.Cancel
300 }
301 } ?: DownResolution.Timeout
302
303 if (downResolution == DownResolution.Cancel) {
304 // On a cancel, we simply take no action.
305 return
306 }
307
308 // For any non-cancel, we will start a selection.
309 observer.onStart(firstDown.position)
310
311 if (downResolution == DownResolution.Up) {
312 // This is a tap, immediately stop and let the initiated selection remain.
313 observer.onStop()
314 return
315 } else if (downResolution == DownResolution.Drag) {
316 // Drag already begun, run a drag on the over-slop and then proceed to wait for drags.
317 observer.onDrag(overSlop)
318 }
319 // Finally, if waitResult was a Timeout, then this was a long press. Simply wait for drags.
320
321 val dragCompletedWithUp =
322 drag(pointerId) {
323 observer.onDrag(it.positionChange())
324 it.consume()
325 }
326
327 if (dragCompletedWithUp) {
328 // consume up if we quit drag gracefully with the up
329 currentEvent.changes.fastForEach {
330 if (it.changedToUp()) {
331 it.consume()
332 }
333 }
334 observer.onStop()
335 } else {
336 observer.onCancel()
337 }
338 } catch (c: CancellationException) {
339 observer.onCancel()
340 throw c
341 }
342 }
343
344 /** Gesture handler for mouse selection. */
mouseSelectionBtf2null345 private suspend fun AwaitPointerEventScope.mouseSelectionBtf2(
346 observer: MouseSelectionObserver,
347 clicksCounter: ClicksCounter,
348 down: PointerEvent
349 ) {
350 val downChange = down.changes[0]
351 if (down.isShiftPressed) {
352 val started = observer.onExtend(downChange.position)
353 if (started) {
354 try {
355 downChange.consume()
356 val shouldConsumeUp =
357 drag(downChange.id) {
358 if (observer.onExtendDrag(it.position)) {
359 it.consume()
360 }
361 }
362
363 if (shouldConsumeUp) {
364 currentEvent.changes.fastForEach { if (it.changedToUp()) it.consume() }
365 }
366 } finally {
367 observer.onDragDone()
368 }
369 }
370 } else {
371 val selectionAdjustment =
372 when (clicksCounter.clicks) {
373 1 -> SelectionAdjustment.None
374 2 -> SelectionAdjustment.Word
375 else -> SelectionAdjustment.Paragraph
376 }
377
378 val started = observer.onStart(downChange.position, selectionAdjustment)
379 if (started) {
380 try {
381 downChange.consume()
382 var dragConsumed = selectionAdjustment != SelectionAdjustment.None
383 val shouldConsumeUp =
384 drag(downChange.id) {
385 if (observer.onDrag(it.position, selectionAdjustment)) {
386 it.consume()
387 dragConsumed = true
388 }
389 }
390
391 if (shouldConsumeUp && dragConsumed) {
392 currentEvent.changes.fastForEach { if (it.changedToUp()) it.consume() }
393 }
394 } finally {
395 observer.onDragDone()
396 }
397 }
398 }
399 }
400
401 private class ClicksCounter(private val viewConfiguration: ViewConfiguration) {
402 var clicks = 0
403 var prevClick: PointerInputChange? = null
404
updatenull405 fun update(event: PointerEvent) {
406 val currentPrevClick = prevClick
407 val newClick = event.changes[0]
408 if (
409 currentPrevClick != null &&
410 timeIsTolerable(currentPrevClick, newClick) &&
411 positionIsTolerable(currentPrevClick, newClick)
412 ) {
413 clicks += 1
414 } else {
415 clicks = 1
416 }
417 prevClick = newClick
418 }
419
timeIsTolerablenull420 fun timeIsTolerable(prevClick: PointerInputChange, newClick: PointerInputChange): Boolean =
421 newClick.uptimeMillis - prevClick.uptimeMillis < viewConfiguration.doubleTapTimeoutMillis
422
423 fun positionIsTolerable(prevClick: PointerInputChange, newClick: PointerInputChange): Boolean =
424 distanceIsTolerable(viewConfiguration, prevClick, newClick)
425 }
426
427 private suspend fun AwaitPointerEventScope.awaitDown(): PointerEvent {
428 var event: PointerEvent
429 do {
430 event = awaitPointerEvent(PointerEventPass.Main)
431 } while (!event.changes.fastAll { it.changedToDownIgnoreConsumed() })
432 return event
433 }
434
distanceIsTolerablenull435 private fun distanceIsTolerable(
436 viewConfiguration: ViewConfiguration,
437 change1: PointerInputChange,
438 change2: PointerInputChange,
439 ): Boolean {
440 val slop = viewConfiguration.pointerSlop(change1.type)
441 return (change1.position - change2.position).getDistance() < slop
442 }
443
444 // TODO(b/281585410) this does not support touch pads as they have a pointer type of Touch
445 // Supporting that will require public api changes
446 // since the necessary info is in the ui module.
447 internal val PointerEvent.isPrecisePointer
<lambda>null448 get() = this.changes.fastAll { it.type == PointerType.Mouse }
449