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