1 /*
2  * Copyright 2020 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.platform.makeSynchronizedObject
20 import androidx.compose.foundation.platform.synchronized
21 import androidx.compose.foundation.text.getLineHeight
22 import androidx.compose.ui.geometry.Offset
23 import androidx.compose.ui.geometry.Rect
24 import androidx.compose.ui.geometry.isUnspecified
25 import androidx.compose.ui.layout.LayoutCoordinates
26 import androidx.compose.ui.text.AnnotatedString
27 import androidx.compose.ui.text.TextLayoutResult
28 import androidx.compose.ui.text.TextRange
29 import kotlin.math.max
30 
31 internal class MultiWidgetSelectionDelegate(
32     override val selectableId: Long,
33     private val coordinatesCallback: () -> LayoutCoordinates?,
34     private val layoutResultCallback: () -> TextLayoutResult?
35 ) : Selectable {
36     private val lock = makeSynchronizedObject(this)
37 
38     private var _previousTextLayoutResult: TextLayoutResult? = null
39 
40     // previously calculated `lastVisibleOffset` for the `_previousTextLayoutResult`
41     private var _previousLastVisibleOffset: Int = -1
42 
43     /**
44      * TextLayoutResult is not expected to change repeatedly in a BasicText composable. At least
45      * most TextLayoutResult changes would likely affect Selection logic in some way. Therefore,
46      * this value only caches the last visible offset calculation for the latest seen
47      * TextLayoutResult instance. Object equality check is not worth the extra calculation as
48      * instance check is enough to accomplish whether a text layout has changed in a meaningful way.
49      */
50     private val TextLayoutResult.lastVisibleOffset: Int
51         get() =
52             synchronized(lock) {
53                 if (_previousTextLayoutResult !== this) {
54                     val lastVisibleLine =
55                         when {
56                             !didOverflowHeight || multiParagraph.didExceedMaxLines -> lineCount - 1
57                             else -> { // size.height < multiParagraph.height
58                                 var finalVisibleLine =
59                                     getLineForVerticalPosition(size.height.toFloat())
60                                         .coerceAtMost(lineCount - 1)
61                                 // if final visible line's top is equal to or larger than text
62                                 // layout
63                                 // result's height, we need to check above lines one by one until we
64                                 // find
65                                 // a line that fits in boundaries.
66                                 while (
67                                     finalVisibleLine >= 0 &&
68                                         getLineTop(finalVisibleLine) >= size.height
69                                 ) finalVisibleLine--
70                                 finalVisibleLine.coerceAtLeast(0)
71                             }
72                         }
73                     _previousLastVisibleOffset = getLineEnd(lastVisibleLine, true)
74                     _previousTextLayoutResult = this
75                 }
76                 _previousLastVisibleOffset
77             }
78 
appendSelectableInfoToBuildernull79     override fun appendSelectableInfoToBuilder(builder: SelectionLayoutBuilder) {
80         val layoutCoordinates = getLayoutCoordinates() ?: return
81         val textLayoutResult = layoutResultCallback() ?: return
82 
83         val relativePosition =
84             builder.containerCoordinates.localPositionOf(layoutCoordinates, Offset.Zero)
85         val localPosition = builder.currentPosition - relativePosition
86         val localPreviousHandlePosition =
87             if (builder.previousHandlePosition.isUnspecified) {
88                 Offset.Unspecified
89             } else {
90                 builder.previousHandlePosition - relativePosition
91             }
92 
93         builder.appendSelectableInfo(
94             textLayoutResult = textLayoutResult,
95             localPosition = localPosition,
96             previousHandlePosition = localPreviousHandlePosition,
97             selectableId = selectableId,
98         )
99     }
100 
getSelectAllSelectionnull101     override fun getSelectAllSelection(): Selection? {
102         val textLayoutResult = layoutResultCallback() ?: return null
103         val start = 0
104         val end = textLayoutResult.layoutInput.text.length
105 
106         return Selection(
107             start =
108                 Selection.AnchorInfo(
109                     direction = textLayoutResult.getBidiRunDirection(start),
110                     offset = start,
111                     selectableId = selectableId
112                 ),
113             end =
114                 Selection.AnchorInfo(
115                     direction = textLayoutResult.getBidiRunDirection(max(end - 1, 0)),
116                     offset = end,
117                     selectableId = selectableId
118                 ),
119             handlesCrossed = false
120         )
121     }
122 
getHandlePositionnull123     override fun getHandlePosition(selection: Selection, isStartHandle: Boolean): Offset {
124         // Check if the selection handle's selectable is the current selectable.
125         if (
126             isStartHandle && selection.start.selectableId != this.selectableId ||
127                 !isStartHandle && selection.end.selectableId != this.selectableId
128         ) {
129             return Offset.Unspecified
130         }
131 
132         if (getLayoutCoordinates() == null) return Offset.Unspecified
133 
134         val textLayoutResult = layoutResultCallback() ?: return Offset.Unspecified
135         val offset = if (isStartHandle) selection.start.offset else selection.end.offset
136         val coercedOffset = offset.coerceIn(0, textLayoutResult.lastVisibleOffset)
137         return getSelectionHandleCoordinates(
138             textLayoutResult = textLayoutResult,
139             offset = coercedOffset,
140             isStart = isStartHandle,
141             areHandlesCrossed = selection.handlesCrossed
142         )
143     }
144 
getLayoutCoordinatesnull145     override fun getLayoutCoordinates(): LayoutCoordinates? {
146         val layoutCoordinates = coordinatesCallback()
147         if (layoutCoordinates == null || !layoutCoordinates.isAttached) return null
148         return layoutCoordinates
149     }
150 
textLayoutResultnull151     override fun textLayoutResult(): TextLayoutResult? {
152         return layoutResultCallback()
153     }
154 
getTextnull155     override fun getText(): AnnotatedString {
156         val textLayoutResult = layoutResultCallback() ?: return AnnotatedString("")
157         return textLayoutResult.layoutInput.text
158     }
159 
getBoundingBoxnull160     override fun getBoundingBox(offset: Int): Rect {
161         val textLayoutResult = layoutResultCallback() ?: return Rect.Zero
162         val textLength = textLayoutResult.layoutInput.text.length
163         if (textLength < 1) return Rect.Zero
164         return textLayoutResult.getBoundingBox(offset.coerceIn(0, textLength - 1))
165     }
166 
getLineLeftnull167     override fun getLineLeft(offset: Int): Float {
168         val textLayoutResult = layoutResultCallback() ?: return -1f
169         val line = textLayoutResult.getLineForOffset(offset)
170         if (line >= textLayoutResult.lineCount) return -1f
171         return textLayoutResult.getLineLeft(line)
172     }
173 
getLineRightnull174     override fun getLineRight(offset: Int): Float {
175         val textLayoutResult = layoutResultCallback() ?: return -1f
176         val line = textLayoutResult.getLineForOffset(offset)
177         if (line >= textLayoutResult.lineCount) return -1f
178         return textLayoutResult.getLineRight(line)
179     }
180 
getCenterYForOffsetnull181     override fun getCenterYForOffset(offset: Int): Float {
182         val textLayoutResult = layoutResultCallback() ?: return -1f
183         val line = textLayoutResult.getLineForOffset(offset)
184         if (line >= textLayoutResult.lineCount) return -1f
185         val top = textLayoutResult.getLineTop(line)
186         val bottom = textLayoutResult.getLineBottom(line)
187         return ((bottom - top) / 2) + top
188     }
189 
getRangeOfLineContainingnull190     override fun getRangeOfLineContaining(offset: Int): TextRange {
191         val textLayoutResult = layoutResultCallback() ?: return TextRange.Zero
192         val visibleTextLength = textLayoutResult.lastVisibleOffset
193         if (visibleTextLength < 1) return TextRange.Zero
194         val line = textLayoutResult.getLineForOffset(offset.coerceIn(0, visibleTextLength - 1))
195         return TextRange(
196             start = textLayoutResult.getLineStart(line),
197             end = textLayoutResult.getLineEnd(line, visibleEnd = true)
198         )
199     }
200 
getLastVisibleOffsetnull201     override fun getLastVisibleOffset(): Int {
202         val textLayoutResult = layoutResultCallback() ?: return 0
203         return textLayoutResult.lastVisibleOffset
204     }
205 
getLineHeightnull206     override fun getLineHeight(offset: Int): Float {
207         return layoutResultCallback()?.getLineHeight(offset) ?: 0f
208     }
209 }
210 
211 /**
212  * Appends a [SelectableInfo] to this [SelectionLayoutBuilder].
213  *
214  * @param textLayoutResult the [TextLayoutResult] for the selectable
215  * @param localPosition the position of the current handle if not being dragged or the drag position
216  *   if it is
217  * @param previousHandlePosition the position of the previous handle
218  * @param selectableId the selectableId for the selectable
219  */
appendSelectableInfonull220 internal fun SelectionLayoutBuilder.appendSelectableInfo(
221     textLayoutResult: TextLayoutResult,
222     localPosition: Offset,
223     previousHandlePosition: Offset,
224     selectableId: Long,
225 ) {
226     val bounds =
227         Rect(
228             0.0f,
229             0.0f,
230             textLayoutResult.size.width.toFloat(),
231             textLayoutResult.size.height.toFloat()
232         )
233 
234     val currentXDirection = getXDirection(localPosition, bounds)
235     val currentYDirection = getYDirection(localPosition, bounds)
236 
237     fun otherDirection(anchor: Selection.AnchorInfo?): Direction =
238         anchor?.let { getDirectionById(it.selectableId, selectableId) }
239             ?: resolve2dDirection(currentXDirection, currentYDirection)
240 
241     val otherDirection: Direction
242     val startXHandleDirection: Direction
243     val startYHandleDirection: Direction
244     val endXHandleDirection: Direction
245     val endYHandleDirection: Direction
246     if (isStartHandle) {
247         otherDirection = otherDirection(previousSelection?.end)
248         startXHandleDirection = currentXDirection
249         startYHandleDirection = currentYDirection
250         endXHandleDirection = otherDirection
251         endYHandleDirection = otherDirection
252     } else {
253         otherDirection = otherDirection(previousSelection?.start)
254         startXHandleDirection = otherDirection
255         startYHandleDirection = otherDirection
256         endXHandleDirection = currentXDirection
257         endYHandleDirection = currentYDirection
258     }
259 
260     if (!isSelected(resolve2dDirection(currentXDirection, currentYDirection), otherDirection)) {
261         return
262     }
263 
264     val textLength = textLayoutResult.layoutInput.text.length
265     val rawStartHandleOffset: Int
266     val rawEndHandleOffset: Int
267     if (isStartHandle) {
268         rawStartHandleOffset = getOffsetForPosition(localPosition, textLayoutResult)
269         rawEndHandleOffset =
270             previousSelection
271                 ?.end
272                 ?.getPreviousAdjustedOffset(
273                     selectableIdOrderingComparator,
274                     selectableId,
275                     textLength
276                 ) ?: rawStartHandleOffset
277     } else {
278         rawEndHandleOffset = getOffsetForPosition(localPosition, textLayoutResult)
279         rawStartHandleOffset =
280             previousSelection
281                 ?.start
282                 ?.getPreviousAdjustedOffset(
283                     selectableIdOrderingComparator,
284                     selectableId,
285                     textLength
286                 ) ?: rawEndHandleOffset
287     }
288 
289     val rawPreviousHandleOffset =
290         if (previousHandlePosition.isUnspecified) -1
291         else {
292             getOffsetForPosition(previousHandlePosition, textLayoutResult)
293         }
294 
295     appendInfo(
296         selectableId = selectableId,
297         rawStartHandleOffset = rawStartHandleOffset,
298         startXHandleDirection = startXHandleDirection,
299         startYHandleDirection = startYHandleDirection,
300         rawEndHandleOffset = rawEndHandleOffset,
301         endXHandleDirection = endXHandleDirection,
302         endYHandleDirection = endYHandleDirection,
303         rawPreviousHandleOffset = rawPreviousHandleOffset,
304         textLayoutResult = textLayoutResult,
305     )
306 }
307 
getPreviousAdjustedOffsetnull308 private fun Selection.AnchorInfo.getPreviousAdjustedOffset(
309     selectableIdOrderingComparator: Comparator<Long>,
310     currentSelectableId: Long,
311     currentTextLength: Int
312 ): Int {
313     val compareResult =
314         selectableIdOrderingComparator.compare(this.selectableId, currentSelectableId)
315 
316     return when {
317         compareResult < 0 -> 0
318         compareResult > 0 -> currentTextLength
319         else -> offset
320     }
321 }
322 
getXDirectionnull323 private fun getXDirection(position: Offset, bounds: Rect): Direction =
324     when {
325         position.x < bounds.left -> Direction.BEFORE
326         position.x > bounds.right -> Direction.AFTER
327         else -> Direction.ON
328     }
329 
getYDirectionnull330 private fun getYDirection(position: Offset, bounds: Rect): Direction =
331     when {
332         position.y < bounds.top -> Direction.BEFORE
333         position.y > bounds.bottom -> Direction.AFTER
334         else -> Direction.ON
335     }
336 
getDirectionByIdnull337 private fun SelectionLayoutBuilder.getDirectionById(
338     anchorSelectableId: Long,
339     currentSelectableId: Long,
340 ): Direction {
341     val compareResult =
342         selectableIdOrderingComparator.compare(anchorSelectableId, currentSelectableId)
343 
344     return when {
345         compareResult < 0 -> Direction.BEFORE
346         compareResult > 0 -> Direction.AFTER
347         else -> Direction.ON
348     }
349 }
350 
351 /**
352  * Returns true if either of the directions are [Direction.ON] or if the directions are not both
353  * [Direction.BEFORE] or [Direction.AFTER].
354  */
isSelectednull355 private fun isSelected(currentDirection: Direction, otherDirection: Direction): Boolean =
356     currentDirection == Direction.ON || currentDirection != otherDirection
357 
358 // map offsets above/below the text to 0/length respectively
359 private fun getOffsetForPosition(position: Offset, textLayoutResult: TextLayoutResult): Int =
360     when {
361         position.y <= 0f -> 0
362         position.y >= textLayoutResult.multiParagraph.height ->
363             textLayoutResult.layoutInput.text.length
364         else -> textLayoutResult.getOffsetForPosition(position)
365     }
366