1 /*
<lambda>null2  * Copyright 2021 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.text.findFollowingBreak
20 import androidx.compose.foundation.text.findPrecedingBreak
21 import androidx.compose.foundation.text.getParagraphBoundary
22 import androidx.compose.ui.text.TextRange
23 
24 /**
25  * Selection can be adjusted depends on context. For example, in touch mode dragging after a long
26  * press adjusts selection by word. But selection by dragging handles is character precise without
27  * adjustments. With a mouse, double-click selects by words and triple-clicks by paragraph.
28  *
29  * @see [SelectionRegistrar.notifySelectionUpdate]
30  */
31 internal fun interface SelectionAdjustment {
32 
33     /**
34      * The callback function that is called once a new selection arrives, the return value of this
35      * function will be the final adjusted [Selection].
36      */
37     fun adjust(layout: SelectionLayout): Selection
38 
39     companion object {
40         /**
41          * The selection adjustment that does nothing and directly return the input raw selection
42          * range.
43          */
44         val None = SelectionAdjustment { layout ->
45             Selection(
46                 start = layout.startInfo.anchorForOffset(layout.startInfo.rawStartHandleOffset),
47                 end = layout.endInfo.anchorForOffset(layout.endInfo.rawEndHandleOffset),
48                 handlesCrossed = layout.crossStatus == CrossStatus.CROSSED
49             )
50         }
51 
52         /**
53          * The character based selection. It normally won't change the raw selection range except
54          * when the input raw selection range is collapsed. In this case, it will almost always make
55          * sure at least one character is selected.
56          */
57         val Character = SelectionAdjustment { layout ->
58             None.adjust(layout).ensureAtLeastOneChar(layout)
59         }
60 
61         /**
62          * The word based selection adjustment. It will adjust the raw input selection such that the
63          * selection boundary snap to the word boundary. It will always expand the raw input
64          * selection range to the closest word boundary. If the raw selection is reversed, it will
65          * always return a reversed selection, and vice versa.
66          */
67         val Word = SelectionAdjustment { layout ->
68             adjustToBoundaries(layout) { textLayoutResult.getWordBoundary(it) }
69         }
70 
71         /**
72          * The paragraph based selection adjustment. It will adjust the raw input selection such
73          * that the selection boundary snap to the paragraph boundary. It will always expand the raw
74          * input selection range to the closest paragraph boundary. If the raw selection is
75          * reversed, it will always return a reversed selection, and vice versa.
76          */
77         val Paragraph = SelectionAdjustment { layout ->
78             adjustToBoundaries(layout) { inputText.getParagraphBoundary(it) }
79         }
80 
81         /**
82          * A special version of character based selection that accelerates the selection update with
83          * word based selection. In short, it expands by word and shrinks by character. Here is more
84          * details of the behavior:
85          * 1. When previous selection is null, it will use word based selection.
86          * 2. When the start/end offset has moved to a different line/Text, it will use word based
87          *    selection.
88          * 3. When the selection is shrinking, it behave same as the character based selection.
89          *    Shrinking means that the start/end offset is moving in the direction that makes
90          *    selected text shorter.
91          * 4. The selection boundary is expanding, a.if the previous start/end offset is not a word
92          *    boundary, use character based selection. b.if the previous start/end offset is a word
93          *    boundary, use word based selection.
94          *
95          * Notice that this selection adjustment assumes that when isStartHandle is true, only start
96          * handle is moving(or unchanged), and vice versa.
97          */
98         val CharacterWithWordAccelerate = SelectionAdjustment { layout ->
99             val previousSelection =
100                 layout.previousSelection ?: return@SelectionAdjustment Word.adjust(layout)
101 
102             val previousAnchor: Selection.AnchorInfo
103             val newAnchor: Selection.AnchorInfo
104             val startAnchor: Selection.AnchorInfo
105             val endAnchor: Selection.AnchorInfo
106 
107             if (layout.isStartHandle) {
108                 previousAnchor = previousSelection.start
109                 newAnchor = layout.updateSelectionBoundary(layout.startInfo, previousAnchor)
110                 startAnchor = newAnchor
111                 endAnchor = previousSelection.end
112             } else {
113                 previousAnchor = previousSelection.end
114                 newAnchor = layout.updateSelectionBoundary(layout.endInfo, previousAnchor)
115                 startAnchor = previousSelection.start
116                 endAnchor = newAnchor
117             }
118 
119             if (newAnchor == previousAnchor) {
120                 // This avoids some cases in BiDi where `layout.crossed` is incorrect.
121                 // In BiDi layout, a single character move gesture can result in the offset
122                 // changing a large amount when crossing over from LTR -> RTL or visa versa.
123                 // This can result in a layout which says it is crossed, but our new selection
124                 // is uncrossed. Instead, just re-use the old selection.
125                 // It also saves an allocation.
126                 previousSelection
127             } else {
128                 val crossed =
129                     layout.crossStatus == CrossStatus.CROSSED ||
130                         (layout.crossStatus == CrossStatus.COLLAPSED &&
131                             startAnchor.offset > endAnchor.offset)
132                 Selection(startAnchor, endAnchor, crossed).ensureAtLeastOneChar(layout)
133             }
134         }
135     }
136 }
137 
138 /** @receiver The selection layout. It is expected that its previousSelection is non-null */
updateSelectionBoundarynull139 private fun SelectionLayout.updateSelectionBoundary(
140     info: SelectableInfo,
141     previousSelectionAnchor: Selection.AnchorInfo
142 ): Selection.AnchorInfo {
143     val currentRawOffset = if (isStartHandle) info.rawStartHandleOffset else info.rawEndHandleOffset
144 
145     val currentSlot = if (isStartHandle) startSlot else endSlot
146     if (currentSlot != info.slot) {
147         // we are between Texts
148         return info.anchorForOffset(currentRawOffset)
149     }
150 
151     val currentRawLine by
152         lazy(LazyThreadSafetyMode.NONE) { info.textLayoutResult.getLineForOffset(currentRawOffset) }
153 
154     val otherRawOffset = if (isStartHandle) info.rawEndHandleOffset else info.rawStartHandleOffset
155 
156     val anchorSnappedToWordBoundary by
157         lazy(LazyThreadSafetyMode.NONE) {
158             info.snapToWordBoundary(
159                 currentLine = currentRawLine,
160                 currentOffset = currentRawOffset,
161                 otherOffset = otherRawOffset,
162                 isStart = isStartHandle,
163                 crossed = crossStatus == CrossStatus.CROSSED
164             )
165         }
166 
167     if (info.selectableId != previousSelectionAnchor.selectableId) {
168         // moved to an entirely new Text, use word based adjustment
169         return anchorSnappedToWordBoundary
170     }
171 
172     val rawPreviousHandleOffset = info.rawPreviousHandleOffset
173     if (currentRawOffset == rawPreviousHandleOffset) {
174         // no change in current handle, return the previous result unchanged
175         return previousSelectionAnchor
176     }
177 
178     val previousRawLine = info.textLayoutResult.getLineForOffset(rawPreviousHandleOffset)
179     // Check raw lines. The previous adjusted selection offset could remain
180     // on a different line after snapping to the word boundary, causing the code to
181     // always seem like it is switching lines and never allowing it to not use the
182     // word boundary offset.
183     if (currentRawLine != previousRawLine) {
184         // Line changed, use word based adjustment.
185         return anchorSnappedToWordBoundary
186     }
187 
188     val previousSelectionOffset = previousSelectionAnchor.offset
189     val previousSelectionWordBoundary =
190         info.textLayoutResult.getWordBoundary(previousSelectionOffset)
191 
192     if (!info.isExpanding(currentRawOffset, isStartHandle)) {
193         // we're shrinking, use the raw offset.
194         return info.anchorForOffset(currentRawOffset)
195     }
196 
197     if (
198         previousSelectionOffset == previousSelectionWordBoundary.start ||
199             previousSelectionOffset == previousSelectionWordBoundary.end
200     ) {
201         // We are expanding, and the previous offset was a word boundary,
202         // so continue using word boundaries.
203         return anchorSnappedToWordBoundary
204     }
205 
206     // We're expanding, but our previousOffset was not at a word boundary. This means
207     // we are adjusting a selection within a word already, so continue to do so.
208     return info.anchorForOffset(currentRawOffset)
209 }
210 
SelectableInfonull211 private fun SelectableInfo.isExpanding(currentRawOffset: Int, isStart: Boolean): Boolean {
212     if (rawPreviousHandleOffset == -1) {
213         return true
214     }
215     if (currentRawOffset == rawPreviousHandleOffset) {
216         return false
217     }
218 
219     val crossed = rawCrossStatus == CrossStatus.CROSSED
220     return if (isStart xor crossed) {
221         currentRawOffset < rawPreviousHandleOffset
222     } else {
223         currentRawOffset > rawPreviousHandleOffset
224     }
225 }
226 
snapToWordBoundarynull227 private fun SelectableInfo.snapToWordBoundary(
228     currentLine: Int,
229     currentOffset: Int,
230     otherOffset: Int,
231     isStart: Boolean,
232     crossed: Boolean,
233 ): Selection.AnchorInfo {
234     val wordBoundary = textLayoutResult.getWordBoundary(currentOffset)
235 
236     // In the case where the target word crosses multiple lines due to hyphenation or
237     // being too long, we use the line start/end to keep the adjusted offset at the
238     // same line.
239     val wordStartLine = textLayoutResult.getLineForOffset(wordBoundary.start)
240     val start =
241         if (wordStartLine == currentLine) {
242             wordBoundary.start
243         } else if (currentLine >= textLayoutResult.lineCount) {
244             // We cannot find the line start, because this line is not even visible.
245             // Since we cannot really select meaningfully in this area,
246             // just use the start of the last visible line.
247             textLayoutResult.getLineStart(textLayoutResult.lineCount - 1)
248         } else {
249             textLayoutResult.getLineStart(currentLine)
250         }
251 
252     val wordEndLine = textLayoutResult.getLineForOffset(wordBoundary.end)
253     val end =
254         if (wordEndLine == currentLine) {
255             wordBoundary.end
256         } else if (currentLine >= textLayoutResult.lineCount) {
257             // We cannot find the line end, because this line is not even visible.
258             // Since we cannot really select meaningfully in this area,
259             // just use the end of the last visible line.
260             textLayoutResult.getLineEnd(textLayoutResult.lineCount - 1)
261         } else {
262             textLayoutResult.getLineEnd(currentLine)
263         }
264 
265     // If one of the word boundary is exactly same as the otherBoundaryOffset, we
266     // can't snap to this word boundary since it will result in an empty selection
267     // range.
268     if (start == otherOffset) {
269         return anchorForOffset(end)
270     }
271     if (end == otherOffset) {
272         return anchorForOffset(start)
273     }
274 
275     val resultOffset =
276         if (isStart xor crossed) {
277             // In this branch when:
278             // 1. selection is updating the start offset, and selection is not reversed.
279             // 2. selection is updating the end offset, and selection is reversed.
280             if (currentOffset <= end) start else end
281         } else {
282             // In this branch when:
283             // 1. selection is updating the end offset, and selection is not reversed.
284             // 2. selection is updating the start offset, and selection is reversed.
285             if (currentOffset >= start) end else start
286         }
287 
288     return anchorForOffset(resultOffset)
289 }
290 
interfacenull291 private fun interface BoundaryFunction {
292     fun SelectableInfo.getBoundary(offset: Int): TextRange
293 }
294 
adjustToBoundariesnull295 private fun adjustToBoundaries(
296     layout: SelectionLayout,
297     boundaryFunction: BoundaryFunction,
298 ): Selection {
299     val crossed = layout.crossStatus == CrossStatus.CROSSED
300     return Selection(
301         start =
302             layout.startInfo.anchorOnBoundary(
303                 crossed = crossed,
304                 isStart = true,
305                 slot = layout.startSlot,
306                 boundaryFunction = boundaryFunction,
307             ),
308         end =
309             layout.endInfo.anchorOnBoundary(
310                 crossed = crossed,
311                 isStart = false,
312                 slot = layout.endSlot,
313                 boundaryFunction = boundaryFunction,
314             ),
315         handlesCrossed = crossed
316     )
317 }
318 
SelectableInfonull319 private fun SelectableInfo.anchorOnBoundary(
320     crossed: Boolean,
321     isStart: Boolean,
322     slot: Int,
323     boundaryFunction: BoundaryFunction,
324 ): Selection.AnchorInfo {
325     val offset = if (isStart) rawStartHandleOffset else rawEndHandleOffset
326 
327     if (slot != this.slot) {
328         return anchorForOffset(offset)
329     }
330 
331     val range = with(boundaryFunction) { getBoundary(offset) }
332 
333     return anchorForOffset(if (isStart xor crossed) range.start else range.end)
334 }
335 
336 /**
337  * This method adjusts the selection to one character respecting [String.findPrecedingBreak] and
338  * [String.findFollowingBreak].
339  */
ensureAtLeastOneCharnull340 internal fun Selection.ensureAtLeastOneChar(layout: SelectionLayout): Selection {
341     // There already is at least one char in this selection, return this selection unchanged.
342     if (!isCollapsed(layout)) {
343         return this
344     }
345 
346     // Exceptions where 0 char selection is acceptable:
347     //   - The selection crosses multiple Texts, but is still collapsed.
348     //       - In the same situation in a single Text, we usually select some whitespace.
349     //         Since there is no whitespace to select, select nothing. Expanding the selection
350     //         into any Texts in this case is likely confusing to the user
351     //         as it is different functionality compared to single text.
352     //   - The previous selection is null, indicating this is the start of a selection.
353     //       - This allows a selection to start off as collapsed. This is necessary for
354     //         Character adjustment to allow an initial collapsed selection, and then once a
355     //         non-collapsed selection is started, this exception goes away.
356     //   - There is no text to select at all, so you can't expand anywhere.
357     val text = layout.currentInfo.inputText
358     if (layout.size > 1 || layout.previousSelection == null || text.isEmpty()) {
359         return this
360     }
361 
362     return expandOneChar(layout)
363 }
364 
365 /** Precondition: the selection is empty. */
expandOneCharnull366 private fun Selection.expandOneChar(layout: SelectionLayout): Selection {
367     val info = layout.currentInfo
368     val text = info.inputText
369     val offset = info.rawStartHandleOffset // start and end are the same, so either works
370 
371     // when the offset is at either boundary of the text,
372     // expand the current handle one character into the text from the boundary.
373     val lastOffset = text.length
374     return when (offset) {
375         0 -> {
376             val followingBreak = text.findFollowingBreak(0)
377             if (layout.isStartHandle) {
378                 copy(start = start.changeOffset(info, followingBreak), handlesCrossed = true)
379             } else {
380                 copy(end = end.changeOffset(info, followingBreak), handlesCrossed = false)
381             }
382         }
383         lastOffset -> {
384             val precedingBreak = text.findPrecedingBreak(lastOffset)
385             if (layout.isStartHandle) {
386                 copy(start = start.changeOffset(info, precedingBreak), handlesCrossed = false)
387             } else {
388                 copy(end = end.changeOffset(info, precedingBreak), handlesCrossed = true)
389             }
390         }
391         else -> {
392             // In cases where offset is not along the boundary,
393             // we will try to maintain the current cross handle states.
394             val crossed = layout.previousSelection?.handlesCrossed == true
395             val newOffset =
396                 if (layout.isStartHandle xor crossed) {
397                     text.findPrecedingBreak(offset)
398                 } else {
399                     text.findFollowingBreak(offset)
400                 }
401 
402             if (layout.isStartHandle) {
403                 copy(start = start.changeOffset(info, newOffset), handlesCrossed = crossed)
404             } else {
405                 copy(end = end.changeOffset(info, newOffset), handlesCrossed = crossed)
406             }
407         }
408     }
409 }
410 
411 // update direction when we are changing the offset since it may be different
changeOffsetnull412 private fun Selection.AnchorInfo.changeOffset(
413     info: SelectableInfo,
414     newOffset: Int,
415 ): Selection.AnchorInfo =
416     copy(offset = newOffset, direction = info.textLayoutResult.getBidiRunDirection(newOffset))
417