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