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.collection.LongIntMap
20 import androidx.collection.LongObjectMap
21 import androidx.collection.MutableLongIntMap
22 import androidx.collection.MutableLongObjectMap
23 import androidx.collection.longObjectMapOf
24 import androidx.collection.mutableLongIntMapOf
25 import androidx.collection.mutableLongObjectMapOf
26 import androidx.compose.foundation.internal.checkPrecondition
27 import androidx.compose.foundation.text.selection.Direction.AFTER
28 import androidx.compose.foundation.text.selection.Direction.BEFORE
29 import androidx.compose.foundation.text.selection.Direction.ON
30 import androidx.compose.ui.geometry.Offset
31 import androidx.compose.ui.layout.LayoutCoordinates
32 import androidx.compose.ui.text.TextLayoutResult
33 import androidx.compose.ui.text.TextRange
34 import androidx.compose.ui.text.style.ResolvedTextDirection
35 import androidx.compose.ui.util.fastForEachIndexed
36
37 /**
38 * Selection data around how the pointer relates to the actual positions of the Text components.
39 *
40 * # Explanation of Slots
41 *
42 * The Slot value is meant to sit either *on* an index or *between* indices. The former means the
43 * pointer is on a `Text` (like slot value `1` and index `0` below). The latter means the pointer is
44 * not on a `Text`, but between `Text`s (like slot value `0` or `2` below). So a slot value of `2`
45 * means that the pointer is between the `Text`s at index `0` and `1`, perhaps in padding or a
46 * non-selectable `Text`.
47 *
48 * ```
49 * slot value 0 1 2 3 4 5 6 7 8 9 10
50 * info index 0 1 2 3 4
51 * ```
52 *
53 * ## Mappings:
54 * The `X` represents an impossible slot assignment The `|`, `/`, and `\` represent a slot mapping.
55 *
56 * ### Mapping minimum slot:
57 * ```
58 * slot value 0 1 2 3 4 5 6 7 8 9 10
59 * \ | \ | \ | \ | \ | X
60 * info index 0 1 2 3 4
61 *
62 * min-slot index = slot / 2
63 * ```
64 *
65 * Minimum slot cannot be after the final `Text` (index `4`).
66 *
67 * ### Mapping maximum slot:
68 * ```
69 * slot value 0 1 2 3 4 5 6 7 8 9 10
70 * X | / | / | / | / | /
71 * info index 0 1 2 3 4
72 * max-slot index = (slot - 1) / 2
73 * ```
74 *
75 * Maximum slot cannot be before the first `Text` (index `0`).
76 *
77 * ## Assertions
78 * * The non-dragging slot should always be directly on a text (odd) because the non-dragging handle
79 * must be anchored somewhere.
80 * * Because of this, we can determine that if `startSlot == endSlot` then it also follows that
81 * `startSlot` and `endSlot` are even.
82 */
83 internal interface SelectionLayout {
84 /** The number of [SelectableInfo]s in this [SelectionLayout]. */
85 val size: Int
86
87 /** The slot of the start anchor. */
88 val startSlot: Int
89
90 /** The slot of the end anchor. */
91 val endSlot: Int
92
93 /** The [CrossStatus] of this layout as determined by the slot/offsets. */
94 val crossStatus: CrossStatus
95
96 /** The [SelectableInfo] that the start is on. */
97 val startInfo: SelectableInfo
98
99 /** The [SelectableInfo] that the end is on. */
100 val endInfo: SelectableInfo
101
102 /** The [SelectableInfo] that the start/end is on as determined by [isStartHandle]. */
103 val currentInfo: SelectableInfo
104
105 /** The [SelectableInfo] for the first selectable on the screen. */
106 val firstInfo: SelectableInfo
107
108 /** The [SelectableInfo] for the last selectable on the screen. */
109 val lastInfo: SelectableInfo
110
111 /**
112 * Run a function on every [SelectableInfo] between [firstInfo] and [lastInfo] (not including
113 * [firstInfo]/[lastInfo]).
114 */
115 fun forEachMiddleInfo(block: (SelectableInfo) -> Unit)
116
117 /** Whether the start or end anchor is currently being moved. */
118 val isStartHandle: Boolean
119
120 /** The previous [Selection] that we are modifying. */
121 val previousSelection: Selection?
122
123 /**
124 * Whether this layout, compared to another layout, has any relevant changes that would require
125 * recomputing selection.
126 *
127 * @param other the selection layout to check for changes compared to this one
128 */
129 fun shouldRecomputeSelection(other: SelectionLayout?): Boolean
130
131 /**
132 * Splits a selection into a Map of selectable ID to Selections limited to that selectable ID.
133 *
134 * @param selection The selection to turn into subSelections
135 */
136 fun createSubSelections(selection: Selection): LongObjectMap<Selection>
137 }
138
139 private class MultiSelectionLayout(
140 val selectableIdToInfoListIndex: LongIntMap,
141 val infoList: List<SelectableInfo>,
142 override val startSlot: Int,
143 override val endSlot: Int,
144 override val isStartHandle: Boolean,
145 override val previousSelection: Selection?,
146 ) : SelectionLayout {
147 init {
<lambda>null148 checkPrecondition(infoList.size > 1) {
149 "MultiSelectionLayout requires an infoList size greater than 1, was ${infoList.size}."
150 }
151 }
152
153 // Most of these properties are unused unless shouldRecomputeSelection returns true,
154 // hence why getters are used everywhere.
155
156 override val size
157 get() = infoList.size
158
159 override val crossStatus: CrossStatus
160 get() =
161 when {
162 startSlot < endSlot -> CrossStatus.NOT_CROSSED
163 startSlot > endSlot -> CrossStatus.CROSSED
164 // because one of the slots is not-dragging, it must be on a text directly
165 // because one of the slots is on a text directly and the start/end slots are equal,
166 // they both must be odd. Given this, dividing the slot by 2 should give us the
167 // correct
168 // info index.
169 else -> infoList[startSlot / 2].rawCrossStatus
170 }
171
172 override val startInfo: SelectableInfo
173 get() = infoList[startOrEndSlotToIndex(startSlot, isStartSlot = true)]
174
175 override val endInfo: SelectableInfo
176 get() = infoList[startOrEndSlotToIndex(endSlot, isStartSlot = false)]
177
178 override val currentInfo: SelectableInfo
179 get() = if (isStartHandle) startInfo else endInfo
180
181 override val firstInfo: SelectableInfo
182 get() = if (crossStatus == CrossStatus.CROSSED) endInfo else startInfo
183
184 override val lastInfo: SelectableInfo
185 get() = if (crossStatus == CrossStatus.CROSSED) startInfo else endInfo
186
forEachMiddleInfonull187 override fun forEachMiddleInfo(block: (SelectableInfo) -> Unit) {
188 val minIndex = getInfoListIndexBySelectableId(firstInfo.selectableId)
189 val maxIndex = getInfoListIndexBySelectableId(lastInfo.selectableId)
190 if (minIndex + 1 >= maxIndex) {
191 return
192 }
193
194 for (i in minIndex + 1 until maxIndex) {
195 block(infoList[i])
196 }
197 }
198
shouldRecomputeSelectionnull199 override fun shouldRecomputeSelection(other: SelectionLayout?): Boolean =
200 previousSelection == null ||
201 other == null ||
202 other !is MultiSelectionLayout ||
203 isStartHandle != other.isStartHandle ||
204 startSlot != other.startSlot ||
205 endSlot != other.endSlot ||
206 shouldAnyInfoRecomputeSelection(other)
207
208 private fun shouldAnyInfoRecomputeSelection(other: MultiSelectionLayout): Boolean {
209 if (size != other.size) return true
210 for (i in infoList.indices) {
211 val thisInfo = infoList[i]
212 val otherInfo = other.infoList[i]
213 if (thisInfo.shouldRecomputeSelection(otherInfo)) {
214 return true
215 }
216 }
217 return false
218 }
219
createSubSelectionsnull220 override fun createSubSelections(selection: Selection): LongObjectMap<Selection> =
221 // Selection is within one selectable, we can return a singleton map of this selection.
222 if (selection.start.selectableId == selection.end.selectableId) {
223 // this check, if not passed, leads to exceptions when selection
224 // highlighting is rendered, so check here instead.
225 checkPrecondition(
226 (selection.handlesCrossed && selection.start.offset >= selection.end.offset) ||
227 (!selection.handlesCrossed && selection.start.offset <= selection.end.offset)
228 ) {
229 "unexpectedly miss-crossed selection: $selection"
230 }
231 longObjectMapOf(selection.start.selectableId, selection)
232 } else
<lambda>null233 mutableLongObjectMapOf<Selection>().apply {
234 val minAnchor = with(selection) { if (handlesCrossed) end else start }
235 createAndPutSubSelection(
236 selection,
237 firstInfo,
238 minAnchor.offset,
239 firstInfo.textLength
240 )
241
242 forEachMiddleInfo { info ->
243 createAndPutSubSelection(selection, info, minOffset = 0, info.textLength)
244 }
245
246 val maxAnchor = with(selection) { if (handlesCrossed) start else end }
247 createAndPutSubSelection(selection, lastInfo, minOffset = 0, maxAnchor.offset)
248 }
249
MutableLongObjectMapnull250 private fun MutableLongObjectMap<Selection>.createAndPutSubSelection(
251 selection: Selection,
252 info: SelectableInfo,
253 minOffset: Int,
254 maxOffset: Int
255 ) {
256 val subSelection =
257 if (selection.handlesCrossed) {
258 info.makeSingleLayoutSelection(start = maxOffset, end = minOffset)
259 } else {
260 info.makeSingleLayoutSelection(start = minOffset, end = maxOffset)
261 }
262
263 // this check, if not passed, leads to exceptions when selection
264 // highlighting is rendered, so check here instead.
265 checkPrecondition(minOffset <= maxOffset) {
266 "minOffset should be less than or equal to maxOffset: $subSelection"
267 }
268
269 put(info.selectableId, subSelection)
270 }
271
toStringnull272 override fun toString(): String =
273 "MultiSelectionLayout(isStartHandle=$isStartHandle, " +
274 "startPosition=${(startSlot + 1).toFloat() / 2}, " +
275 "endPosition=${(endSlot + 1).toFloat() / 2}, " +
276 "crossed=$crossStatus, " +
277 "infos=${
278 buildString {
279 append("[\n\t")
280 var first = true
281 infoList
282 .fastForEachIndexed { index, info ->
283 if (first) {
284 first = false
285 } else {
286 append(",\n\t")
287 }
288 append("${(index + 1)} -> $info")
289 }
290 append("\n]")
291 }
292 })"
293
startOrEndSlotToIndexnull294 private fun startOrEndSlotToIndex(slot: Int, isStartSlot: Boolean): Int =
295 slotToIndex(
296 slot = slot,
297 isMinimumSlot =
298 when (crossStatus) {
299 // collapsed: doesn't matter whether true or false, it will result in the same
300 // index
301 CrossStatus.COLLAPSED -> true
302 CrossStatus.NOT_CROSSED -> isStartSlot
303 CrossStatus.CROSSED -> !isStartSlot
304 }
305 )
306
slotToIndexnull307 private fun slotToIndex(slot: Int, isMinimumSlot: Boolean): Int {
308 val slotAdjustment = if (isMinimumSlot) 0 else 1
309 return (slot - slotAdjustment) / 2
310 }
311
getInfoListIndexBySelectableIdnull312 private fun getInfoListIndexBySelectableId(id: Long): Int =
313 try {
314 selectableIdToInfoListIndex[id]
315 } catch (e: NoSuchElementException) {
316 throw IllegalStateException("Invalid selectableId: $id", e)
317 }
318 }
319
320 /**
321 * Create a selection layout that has only one slot.
322 *
323 * @param isStartHandle whether this is the start or end anchor
324 * @param previousSelection the previous selection
325 * @param info the single [SelectableInfo]
326 */
327 private class SingleSelectionLayout(
328 override val isStartHandle: Boolean,
329 override val startSlot: Int,
330 override val endSlot: Int,
331 override val previousSelection: Selection?,
332 private val info: SelectableInfo,
333 ) : SelectionLayout {
334 companion object {
335 const val DEFAULT_SLOT = 1
336 const val DEFAULT_SELECTABLE_ID = 1L
337 }
338
339 override val size
340 get() = 1
341
342 override val crossStatus: CrossStatus
343 get() =
344 when {
345 startSlot < endSlot -> CrossStatus.NOT_CROSSED
346 startSlot > endSlot -> CrossStatus.CROSSED
347 else -> info.rawCrossStatus
348 }
349
350 override val startInfo: SelectableInfo
351 get() = info
352
353 override val endInfo: SelectableInfo
354 get() = info
355
356 override val currentInfo: SelectableInfo
357 get() = info
358
359 override val firstInfo: SelectableInfo
360 get() = info
361
362 override val lastInfo: SelectableInfo
363 get() = info
364
forEachMiddleInfonull365 override fun forEachMiddleInfo(block: (SelectableInfo) -> Unit) {
366 // there are no middle infos, so do nothing
367 }
368
shouldRecomputeSelectionnull369 override fun shouldRecomputeSelection(other: SelectionLayout?): Boolean =
370 previousSelection == null ||
371 other == null ||
372 other !is SingleSelectionLayout ||
373 startSlot != other.startSlot ||
374 endSlot != other.endSlot ||
375 isStartHandle != other.isStartHandle ||
376 info.shouldRecomputeSelection(other.info)
377
378 override fun createSubSelections(selection: Selection): LongObjectMap<Selection> {
379 val finalSelection =
380 selection.run {
381 // uncross handles if necessary
382 if (
383 (!handlesCrossed && start.offset > end.offset) ||
384 (handlesCrossed && start.offset <= end.offset)
385 ) {
386 copy(handlesCrossed = !handlesCrossed)
387 } else {
388 this
389 }
390 }
391 return longObjectMapOf(info.selectableId, finalSelection)
392 }
393
toStringnull394 override fun toString(): String =
395 "SingleSelectionLayout(isStartHandle=$isStartHandle, crossed=$crossStatus, info=\n\t$info)"
396 }
397
398 /**
399 * Create a selection layout that has only one slot.
400 *
401 * This is intended for TextField, where multiple selectables is of no concern.
402 *
403 * @param layoutResult the [TextLayoutResult] for the text field
404 * @param rawStartHandleOffset the index of the start handle
405 * @param rawEndHandleOffset the index of the end handle
406 * @param rawPreviousHandleOffset the previous handle offset based on [isStartHandle], or
407 * [UNASSIGNED_SLOT] if none
408 * @param previousSelectionRange the previous selection
409 * @param isStartOfSelection whether this is the start of a selection gesture (no previous context)
410 * @param isStartHandle whether this is the start or end anchor
411 */
412 internal fun getTextFieldSelectionLayout(
413 layoutResult: TextLayoutResult,
414 rawStartHandleOffset: Int,
415 rawEndHandleOffset: Int,
416 rawPreviousHandleOffset: Int,
417 previousSelectionRange: TextRange,
418 isStartOfSelection: Boolean,
419 isStartHandle: Boolean,
420 ): SelectionLayout =
421 SingleSelectionLayout(
422 isStartHandle = isStartHandle,
423 startSlot = SingleSelectionLayout.DEFAULT_SLOT,
424 endSlot = SingleSelectionLayout.DEFAULT_SLOT,
425 previousSelection =
426 if (isStartOfSelection) null
427 else
428 Selection(
429 start =
430 Selection.AnchorInfo(
431 layoutResult.getTextDirectionForOffset(previousSelectionRange.start),
432 previousSelectionRange.start,
433 SingleSelectionLayout.DEFAULT_SELECTABLE_ID
434 ),
435 end =
436 Selection.AnchorInfo(
437 layoutResult.getTextDirectionForOffset(previousSelectionRange.end),
438 previousSelectionRange.end,
439 SingleSelectionLayout.DEFAULT_SELECTABLE_ID
440 ),
441 handlesCrossed = previousSelectionRange.reversed
442 ),
443 info =
444 SelectableInfo(
445 selectableId = SingleSelectionLayout.DEFAULT_SELECTABLE_ID,
446 slot = SingleSelectionLayout.DEFAULT_SLOT,
447 rawStartHandleOffset = rawStartHandleOffset,
448 rawEndHandleOffset = rawEndHandleOffset,
449 textLayoutResult = layoutResult,
450 rawPreviousHandleOffset = rawPreviousHandleOffset
451 ),
452 )
453
454 /** Whether something is crossed as determined by the position of the start/end. */
455 internal enum class CrossStatus {
456 /** The start comes after the end. */
457 CROSSED,
458
459 /** The start comes before the end. */
460 NOT_CROSSED,
461
462 /** The start is the same as the end. */
463 COLLAPSED
464 }
465
466 /** Slot has not been assigned yet */
467 internal const val UNASSIGNED_SLOT = -1
468
469 /**
470 * A builder for [SelectionLayout] that ensures the data structures and slots are properly
471 * constructed.
472 *
473 * @param previousHandlePosition the previous handle position matching the handle directed to by
474 * [isStartHandle]
475 * @param containerCoordinates the coordinates of the [SelectionContainer] for converting
476 * [SelectionContainer] coordinates to their respective [Selectable] coordinates
477 * @param isStartHandle whether the currently pressed/clicked handle is the start
478 * @param selectableIdOrderingComparator determines the ordering of selectables by their IDs
479 */
480 internal class SelectionLayoutBuilder(
481 val currentPosition: Offset,
482 val previousHandlePosition: Offset,
483 val containerCoordinates: LayoutCoordinates,
484 val isStartHandle: Boolean,
485 val previousSelection: Selection?,
486 val selectableIdOrderingComparator: Comparator<Long>
487 ) {
488 private val selectableIdToInfoListIndex: MutableLongIntMap = mutableLongIntMapOf()
489 private val infoList: MutableList<SelectableInfo> = mutableListOf()
490 private var startSlot: Int = UNASSIGNED_SLOT
491 private var endSlot: Int = UNASSIGNED_SLOT
492 private var currentSlot: Int = UNASSIGNED_SLOT
493
494 /**
495 * Finishes building the [SelectionLayout] and returns it.
496 *
497 * @return the [SelectionLayout] or null if no [SelectableInfo]s were added.
498 */
buildnull499 fun build(): SelectionLayout? {
500 val lastSlot = currentSlot + 1
501 return when (infoList.size) {
502 0 -> {
503 return null
504 }
505 1 -> {
506 SingleSelectionLayout(
507 info = infoList.single(),
508 startSlot = if (startSlot == UNASSIGNED_SLOT) lastSlot else startSlot,
509 endSlot = if (endSlot == UNASSIGNED_SLOT) lastSlot else endSlot,
510 previousSelection = previousSelection,
511 isStartHandle = isStartHandle,
512 )
513 }
514 else -> {
515 MultiSelectionLayout(
516 selectableIdToInfoListIndex = selectableIdToInfoListIndex,
517 infoList = infoList,
518 startSlot = if (startSlot == UNASSIGNED_SLOT) lastSlot else startSlot,
519 endSlot = if (endSlot == UNASSIGNED_SLOT) lastSlot else endSlot,
520 isStartHandle = isStartHandle,
521 previousSelection = previousSelection,
522 )
523 }
524 }
525 }
526
527 /** Appends a selection info to this builder. */
appendInfonull528 fun appendInfo(
529 selectableId: Long,
530 rawStartHandleOffset: Int,
531 startXHandleDirection: Direction,
532 startYHandleDirection: Direction,
533 rawEndHandleOffset: Int,
534 endXHandleDirection: Direction,
535 endYHandleDirection: Direction,
536 rawPreviousHandleOffset: Int,
537 textLayoutResult: TextLayoutResult,
538 ): SelectableInfo {
539 // We need currentSlot to equal the slot of the "last" info when getLayout is called,
540 // so increment this before adding the info and leave the correct slot in place at the end.
541 currentSlot += 2
542
543 val selectableInfo =
544 SelectableInfo(
545 selectableId = selectableId,
546 slot = currentSlot,
547 rawStartHandleOffset = rawStartHandleOffset,
548 rawEndHandleOffset = rawEndHandleOffset,
549 rawPreviousHandleOffset = rawPreviousHandleOffset,
550 textLayoutResult = textLayoutResult,
551 )
552
553 startSlot = updateSlot(startSlot, startXHandleDirection, startYHandleDirection)
554 endSlot = updateSlot(endSlot, endXHandleDirection, endYHandleDirection)
555 selectableIdToInfoListIndex[selectableId] = infoList.size
556 infoList += selectableInfo
557 return selectableInfo
558 }
559
560 /**
561 * Find the slot for a selectable given the current position's directions from the selectable.
562 *
563 * The selectables must be ordered in the order in which they would be selected, and then this
564 * function should be called for each of those selectables.
565 *
566 * It is expected that the input [slot] is also assigned the result of this function.
567 *
568 * This function is stateful.
569 *
570 * @param slot the current value of this slot.
571 * @param xPositionDirection Where the x-position is relative to the selectable
572 * @param yPositionDirection Where the y-position is relative to the selectable
573 */
updateSlotnull574 private fun updateSlot(
575 slot: Int,
576 xPositionDirection: Direction,
577 yPositionDirection: Direction,
578 ): Int {
579 if (slot != UNASSIGNED_SLOT) {
580 // don't overwrite if the slot has already been determined
581 return slot
582 }
583
584 // slot has not been determined yet,
585 // see if we are on or past the selectable we are looking for
586 return when (resolve2dDirection(xPositionDirection, yPositionDirection)) {
587 // If we get here, that means we never found a selectable that contains our gesture
588 // position. This is the first selectable that is after the position,
589 // so our slot must be between the previous and current selectables.
590 BEFORE -> currentSlot - 1
591
592 // The gesture position is directly on this selectable, so use this one.
593 ON -> currentSlot
594
595 // keep looking
596 AFTER -> slot
597 }
598 }
599 }
600
601 /** Where the position of a cursor/press is compared to a selectable. */
602 internal enum class Direction {
603 /** The cursor/press is before the selectable */
604 BEFORE,
605
606 /** The cursor/press is on the selectable */
607 ON,
608
609 /** The cursor/press is after the selectable */
610 AFTER
611 }
612
613 /**
614 * Determine direction based on an x/y direction.
615 *
616 * This will use the [y] direction unless it is [ON], in which case it will use the [x] direction.
617 */
resolve2dDirectionnull618 internal fun resolve2dDirection(x: Direction, y: Direction): Direction =
619 when (y) {
620 BEFORE -> BEFORE
621 ON ->
622 when (x) {
623 BEFORE -> BEFORE
624 ON -> ON
625 AFTER -> AFTER
626 }
627 AFTER -> AFTER
628 }
629
630 /** Data about a specific selectable within a [SelectionLayout]. */
631 internal class SelectableInfo(
632 val selectableId: Long,
633 val slot: Int,
634 val rawStartHandleOffset: Int,
635 val rawEndHandleOffset: Int,
636 val rawPreviousHandleOffset: Int,
637 val textLayoutResult: TextLayoutResult,
638 ) {
639
640 /** The [String] in the selectable. */
641 val inputText: String
642 get() = textLayoutResult.layoutInput.text.text
643
644 /** The length of the [String] in the selectable. */
645 val textLength: Int
646 get() = inputText.length
647
648 /** Whether the raw offsets of this info are crossed. */
649 val rawCrossStatus: CrossStatus
650 get() =
651 when {
652 rawStartHandleOffset < rawEndHandleOffset -> CrossStatus.NOT_CROSSED
653 rawStartHandleOffset > rawEndHandleOffset -> CrossStatus.CROSSED
654 else -> CrossStatus.COLLAPSED
655 }
656
657 private val startRunDirection
658 get() = textLayoutResult.getTextDirectionForOffset(rawStartHandleOffset)
659
660 private val endRunDirection
661 get() = textLayoutResult.getTextDirectionForOffset(rawEndHandleOffset)
662
663 /**
664 * Whether this info, compared to another info, has any relevant changes that would require
665 * recomputing selection.
666 *
667 * @param other the selectable info to check for changes compared to this one
668 */
shouldRecomputeSelectionnull669 fun shouldRecomputeSelection(other: SelectableInfo): Boolean =
670 selectableId != other.selectableId ||
671 rawStartHandleOffset != other.rawStartHandleOffset ||
672 rawEndHandleOffset != other.rawEndHandleOffset
673
674 /** Get a [Selection.AnchorInfo] for this [SelectableInfo] at the given [offset]. */
675 fun anchorForOffset(offset: Int): Selection.AnchorInfo =
676 Selection.AnchorInfo(
677 direction = textLayoutResult.getTextDirectionForOffset(offset),
678 offset = offset,
679 selectableId = selectableId
680 )
681
682 /**
683 * Get a [Selection] within the selectable represented by this [SelectableInfo] for the given
684 * [start] and [end] offsets.
685 */
686 fun makeSingleLayoutSelection(start: Int, end: Int): Selection =
687 Selection(
688 start = anchorForOffset(start),
689 end = anchorForOffset(end),
690 handlesCrossed = start > end
691 )
692
693 override fun toString(): String =
694 "SelectionInfo(id=$selectableId, " +
695 "range=($rawStartHandleOffset-$startRunDirection,$rawEndHandleOffset-$endRunDirection), " +
696 "prevOffset=$rawPreviousHandleOffset)"
697 }
698
699 /**
700 * Get the text direction for a given offset.
701 *
702 * This simply calls [TextLayoutResult.getBidiRunDirection] with one exception, if the offset is an
703 * empty line, then we defer to [TextLayoutResult.multiParagraph] and
704 * [androidx.compose.ui.text.MultiParagraph.getParagraphDirection]. This is because an empty line
705 * always resolves to LTR, even if the paragraph is RTL.
706 */
707 // TODO(b/295197585)
708 // Can this logic be moved to a new method in `androidx.compose.ui.text.Paragraph`?
709 private fun TextLayoutResult.getTextDirectionForOffset(offset: Int): ResolvedTextDirection =
710 if (isOffsetAnEmptyLine(offset)) getParagraphDirection(offset) else getBidiRunDirection(offset)
711
712 private fun TextLayoutResult.isOffsetAnEmptyLine(offset: Int): Boolean =
713 layoutInput.text.isEmpty() ||
714 getLineForOffset(offset).let { currentLine ->
715 // verify the previous and next offsets either don't exist because they're at a boundary
716 // or that they are different lines than the current line.
717 (offset == 0 || currentLine != getLineForOffset(offset - 1)) &&
718 (offset == layoutInput.text.length || currentLine != getLineForOffset(offset + 1))
719 }
720
721 /**
722 * Verify that the selection is truly collapsed.
723 *
724 * If the selection is contained within one selectable, this simply checks if the offsets are equal.
725 *
726 * If the Selection spans multiple selectables, then this will verify that every selected selectable
727 * contains a zero-width selection.
728 */
isCollapsednull729 internal fun Selection?.isCollapsed(layout: SelectionLayout?): Boolean {
730 this ?: return true
731 layout ?: return true
732
733 // Selection is within one selectable, simply check if the offsets are the same.
734 if (start.selectableId == end.selectableId) {
735 return start.offset == end.offset
736 }
737
738 // check that maxAnchor offset is 0, else the selection cannot be collapsed.
739 val maxAnchor = if (handlesCrossed) start else end
740 if (maxAnchor.offset != 0) {
741 return false
742 }
743
744 // check that the minAnchor offset is equal to the length of the text,
745 // else the selection is not collapsed
746 val minAnchor = if (handlesCrossed) end else start
747 if (layout.firstInfo.textLength != minAnchor.offset) {
748 return false
749 }
750
751 // Every selectable between the min and max must have empty text,
752 // else there is some text selected.
753 var allTextsEmpty = true
754 layout.forEachMiddleInfo {
755 if (it.inputText.isNotEmpty()) {
756 allTextsEmpty = false
757 }
758 }
759
760 return allTextsEmpty
761 }
762