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