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