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.modifiers
18 
19 import androidx.compose.foundation.text.TextDragObserver
20 import androidx.compose.foundation.text.selection.MouseSelectionObserver
21 import androidx.compose.foundation.text.selection.MultiWidgetSelectionDelegate
22 import androidx.compose.foundation.text.selection.Selectable
23 import androidx.compose.foundation.text.selection.SelectionAdjustment
24 import androidx.compose.foundation.text.selection.SelectionRegistrar
25 import androidx.compose.foundation.text.selection.hasSelection
26 import androidx.compose.foundation.text.selection.selectionGestureInput
27 import androidx.compose.runtime.RememberObserver
28 import androidx.compose.ui.Modifier
29 import androidx.compose.ui.geometry.Offset
30 import androidx.compose.ui.graphics.Color
31 import androidx.compose.ui.graphics.Path
32 import androidx.compose.ui.graphics.drawscope.DrawScope
33 import androidx.compose.ui.graphics.drawscope.clipRect
34 import androidx.compose.ui.input.pointer.PointerIcon
35 import androidx.compose.ui.input.pointer.pointerHoverIcon
36 import androidx.compose.ui.layout.LayoutCoordinates
37 import androidx.compose.ui.text.TextLayoutResult
38 import androidx.compose.ui.text.style.TextOverflow
39 
40 internal open class StaticTextSelectionParams(
41     val layoutCoordinates: LayoutCoordinates?,
42     val textLayoutResult: TextLayoutResult?
43 ) {
44     companion object {
45         val Empty = StaticTextSelectionParams(null, null)
46     }
47 
48     open fun getPathForRange(start: Int, end: Int): Path? {
49         return textLayoutResult?.getPathForRange(start, end)
50     }
51 
52     open val shouldClip: Boolean
53         get() =
54             textLayoutResult?.let {
55                 it.layoutInput.overflow != TextOverflow.Visible && it.hasVisualOverflow
56             } ?: false
57 
58     // if this copy shows up in traces, this class may become mutable
59     fun copy(
60         layoutCoordinates: LayoutCoordinates? = this.layoutCoordinates,
61         textLayoutResult: TextLayoutResult? = this.textLayoutResult
62     ): StaticTextSelectionParams {
63         return StaticTextSelectionParams(layoutCoordinates, textLayoutResult)
64     }
65 }
66 
67 /** Holder for selection modifiers while we wait for pointerInput to be ported to new modifiers. */
68 // This is _basically_ a Modifier.Node but moved into remember because we need to do pointerInput
69 internal class SelectionController(
70     private val selectableId: Long,
71     private val selectionRegistrar: SelectionRegistrar,
72     private val backgroundSelectionColor: Color,
73     // TODO: Move these into Modifier.element eventually
74     private var params: StaticTextSelectionParams = StaticTextSelectionParams.Empty
75 ) : RememberObserver {
76     private var selectable: Selectable? = null
77 
78     val modifier: Modifier =
79         selectionRegistrar
80             .makeSelectionModifier(
81                 selectableId = selectableId,
<lambda>null82                 layoutCoordinates = { params.layoutCoordinates },
83             )
84             .pointerHoverIcon(PointerIcon.Text)
85 
onRememberednull86     override fun onRemembered() {
87         selectable =
88             selectionRegistrar.subscribe(
89                 MultiWidgetSelectionDelegate(
90                     selectableId = selectableId,
91                     coordinatesCallback = { params.layoutCoordinates },
92                     layoutResultCallback = { params.textLayoutResult }
93                 )
94             )
95     }
96 
onForgottennull97     override fun onForgotten() {
98         val localSelectable = selectable
99         if (localSelectable != null) {
100             selectionRegistrar.unsubscribe(localSelectable)
101             selectable = null
102         }
103     }
104 
onAbandonednull105     override fun onAbandoned() {
106         val localSelectable = selectable
107         if (localSelectable != null) {
108             selectionRegistrar.unsubscribe(localSelectable)
109             selectable = null
110         }
111     }
112 
updateTextLayoutnull113     fun updateTextLayout(textLayoutResult: TextLayoutResult) {
114         val prevTextLayoutResult = params.textLayoutResult
115 
116         // Don't notify on null. We don't want every new Text that enters composition to
117         // notify a selectable change. It was already handled when it was created.
118         if (
119             prevTextLayoutResult != null &&
120                 prevTextLayoutResult.layoutInput.text != textLayoutResult.layoutInput.text
121         ) {
122             // Text content changed, notify selection to update itself.
123             selectionRegistrar.notifySelectableChange(selectableId)
124         }
125         params = params.copy(textLayoutResult = textLayoutResult)
126     }
127 
updateGlobalPositionnull128     fun updateGlobalPosition(coordinates: LayoutCoordinates) {
129         params = params.copy(layoutCoordinates = coordinates)
130         selectionRegistrar.notifyPositionChange(selectableId)
131     }
132 
drawnull133     fun draw(drawScope: DrawScope) {
134         val selection = selectionRegistrar.subselections[selectableId] ?: return
135 
136         val start =
137             if (!selection.handlesCrossed) {
138                 selection.start.offset
139             } else {
140                 selection.end.offset
141             }
142         val end =
143             if (!selection.handlesCrossed) {
144                 selection.end.offset
145             } else {
146                 selection.start.offset
147             }
148 
149         if (start == end) return
150 
151         val lastOffset = selectable?.getLastVisibleOffset() ?: 0
152         val clippedStart = start.coerceAtMost(lastOffset)
153         val clippedEnd = end.coerceAtMost(lastOffset)
154 
155         val selectionPath = params.getPathForRange(clippedStart, clippedEnd) ?: return
156 
157         with(drawScope) {
158             if (params.shouldClip) {
159                 clipRect { drawPath(selectionPath, backgroundSelectionColor) }
160             } else {
161                 drawPath(selectionPath, backgroundSelectionColor)
162             }
163         }
164     }
165 }
166 
167 // this is not chained, but is a standalone factory
168 @Suppress("ModifierFactoryExtensionFunction")
SelectionRegistrarnull169 private fun SelectionRegistrar.makeSelectionModifier(
170     selectableId: Long,
171     layoutCoordinates: () -> LayoutCoordinates?,
172 ): Modifier {
173     val longPressDragObserver =
174         object : TextDragObserver {
175             /**
176              * The beginning position of the drag gesture. Every time a new drag gesture starts, it
177              * wil be recalculated.
178              */
179             var lastPosition = Offset.Zero
180 
181             /**
182              * The total distance being dragged of the drag gesture. Every time a new drag gesture
183              * starts, it will be zeroed out.
184              */
185             var dragTotalDistance = Offset.Zero
186 
187             override fun onDown(point: Offset) {
188                 // Not supported for long-press-drag.
189             }
190 
191             override fun onUp() {
192                 // Nothing to do.
193             }
194 
195             override fun onStart(startPoint: Offset) {
196                 layoutCoordinates()?.let {
197                     if (!it.isAttached) return
198 
199                     notifySelectionUpdateStart(
200                         layoutCoordinates = it,
201                         startPosition = startPoint,
202                         adjustment = SelectionAdjustment.Word,
203                         isInTouchMode = true
204                     )
205 
206                     lastPosition = startPoint
207                 }
208                 // selection never started
209                 if (!hasSelection(selectableId)) return
210                 // Zero out the total distance that being dragged.
211                 dragTotalDistance = Offset.Zero
212             }
213 
214             override fun onDrag(delta: Offset) {
215                 layoutCoordinates()?.let {
216                     if (!it.isAttached) return
217                     // selection never started, did not consume any drag
218                     if (!hasSelection(selectableId)) return
219 
220                     dragTotalDistance += delta
221                     val newPosition = lastPosition + dragTotalDistance
222 
223                     // Notice that only the end position needs to be updated here.
224                     // Start position is left unchanged. This is typically important when
225                     // long-press is using SelectionAdjustment.WORD or
226                     // SelectionAdjustment.PARAGRAPH that updates the start handle position from
227                     // the dragBeginPosition.
228                     val consumed =
229                         notifySelectionUpdate(
230                             layoutCoordinates = it,
231                             previousPosition = lastPosition,
232                             newPosition = newPosition,
233                             isStartHandle = false,
234                             adjustment = SelectionAdjustment.Word,
235                             isInTouchMode = true
236                         )
237                     if (consumed) {
238                         lastPosition = newPosition
239                         dragTotalDistance = Offset.Zero
240                     }
241                 }
242             }
243 
244             override fun onStop() {
245                 if (hasSelection(selectableId)) {
246                     notifySelectionUpdateEnd()
247                 }
248             }
249 
250             override fun onCancel() {
251                 if (hasSelection(selectableId)) {
252                     notifySelectionUpdateEnd()
253                 }
254             }
255         }
256 
257     val mouseSelectionObserver =
258         object : MouseSelectionObserver {
259             var lastPosition = Offset.Zero
260 
261             override fun onExtend(downPosition: Offset): Boolean {
262                 layoutCoordinates()?.let { layoutCoordinates ->
263                     if (!layoutCoordinates.isAttached) return false
264                     val consumed =
265                         notifySelectionUpdate(
266                             layoutCoordinates = layoutCoordinates,
267                             newPosition = downPosition,
268                             previousPosition = lastPosition,
269                             isStartHandle = false,
270                             adjustment = SelectionAdjustment.None,
271                             isInTouchMode = false
272                         )
273                     if (consumed) {
274                         lastPosition = downPosition
275                     }
276                     return hasSelection(selectableId)
277                 }
278                 return false
279             }
280 
281             override fun onExtendDrag(dragPosition: Offset): Boolean {
282                 layoutCoordinates()?.let { layoutCoordinates ->
283                     if (!layoutCoordinates.isAttached) return false
284                     if (!hasSelection(selectableId)) return false
285 
286                     val consumed =
287                         notifySelectionUpdate(
288                             layoutCoordinates = layoutCoordinates,
289                             newPosition = dragPosition,
290                             previousPosition = lastPosition,
291                             isStartHandle = false,
292                             adjustment = SelectionAdjustment.None,
293                             isInTouchMode = false
294                         )
295 
296                     if (consumed) {
297                         lastPosition = dragPosition
298                     }
299                 }
300                 return true
301             }
302 
303             override fun onStart(downPosition: Offset, adjustment: SelectionAdjustment): Boolean {
304                 layoutCoordinates()?.let {
305                     if (!it.isAttached) return false
306 
307                     notifySelectionUpdateStart(
308                         layoutCoordinates = it,
309                         startPosition = downPosition,
310                         adjustment = adjustment,
311                         isInTouchMode = false
312                     )
313 
314                     lastPosition = downPosition
315                     return hasSelection(selectableId)
316                 }
317 
318                 return false
319             }
320 
321             override fun onDrag(dragPosition: Offset, adjustment: SelectionAdjustment): Boolean {
322                 layoutCoordinates()?.let {
323                     if (!it.isAttached) return false
324                     if (!hasSelection(selectableId)) return false
325 
326                     val consumed =
327                         notifySelectionUpdate(
328                             layoutCoordinates = it,
329                             previousPosition = lastPosition,
330                             newPosition = dragPosition,
331                             isStartHandle = false,
332                             adjustment = adjustment,
333                             isInTouchMode = false
334                         )
335                     if (consumed) {
336                         lastPosition = dragPosition
337                     }
338                 }
339                 return true
340             }
341 
342             override fun onDragDone() {
343                 notifySelectionUpdateEnd()
344             }
345         }
346 
347     return Modifier.selectionGestureInput(mouseSelectionObserver, longPressDragObserver)
348 }
349