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.TextLayoutResultProxy
20 import androidx.compose.foundation.text.findCodePointOrEmojiStartBefore
21 import androidx.compose.foundation.text.findFollowingBreak
22 import androidx.compose.foundation.text.findParagraphEnd
23 import androidx.compose.foundation.text.findParagraphStart
24 import androidx.compose.foundation.text.findPrecedingBreak
25 import androidx.compose.ui.geometry.Offset
26 import androidx.compose.ui.geometry.Rect
27 import androidx.compose.ui.text.AnnotatedString
28 import androidx.compose.ui.text.TextLayoutResult
29 import androidx.compose.ui.text.TextRange
30 import androidx.compose.ui.text.input.CommitTextCommand
31 import androidx.compose.ui.text.input.EditCommand
32 import androidx.compose.ui.text.input.OffsetMapping
33 import androidx.compose.ui.text.input.SetSelectionCommand
34 import androidx.compose.ui.text.input.TextFieldValue
35 import androidx.compose.ui.text.style.ResolvedTextDirection
36 
37 internal class TextPreparedSelectionState {
38     // it's set at the start of vertical navigation and used as the preferred value to set a new
39     // cursor position.
40     var cachedX: Float? = null
41 
42     fun resetCachedX() {
43         cachedX = null
44     }
45 }
46 
47 /**
48  * This utility class implements many selection-related operations on text (including basic cursor
49  * movements and deletions) and combines them, taking into account how the text was rendered. So,
50  * for example, [moveCursorToLineEnd] moves it to the visual line end.
51  *
52  * For many of these operations, it's particularly important to keep the difference between
53  * selection start and selection end. In some systems, they are called "anchor" and "caret"
54  * respectively. For example, for selection from scratch, after [moveCursorLeftByWord]
55  * [moveCursorRight] will move the left side of the selection, but after [moveCursorRightByWord] the
56  * right one.
57  *
58  * To use it in scope of text fields see [TextFieldPreparedSelection]
59  */
60 internal abstract class BaseTextPreparedSelection<T : BaseTextPreparedSelection<T>>(
61     val originalText: AnnotatedString,
62     val originalSelection: TextRange,
63     val layoutResult: TextLayoutResult?,
64     val offsetMapping: OffsetMapping,
65     val state: TextPreparedSelectionState
66 ) {
67     var selection = originalSelection
68 
69     var annotatedString = originalText
70     internal val text
71         get() = annotatedString.text
72 
73     @Suppress("UNCHECKED_CAST")
applynull74     protected inline fun <U> U.apply(resetCachedX: Boolean = true, block: U.() -> Unit): T {
75         if (resetCachedX) {
76             state.resetCachedX()
77         }
78         if (text.isNotEmpty()) {
79             block()
80         }
81         return this as T
82     }
83 
setCursornull84     protected fun setCursor(offset: Int) {
85         setSelection(offset, offset)
86     }
87 
setSelectionnull88     protected fun setSelection(start: Int, end: Int) {
89         selection = TextRange(start, end)
90     }
91 
<lambda>null92     fun selectAll() = apply { setSelection(0, text.length) }
93 
<lambda>null94     fun deselect() = apply { setCursor(selection.end) }
95 
<lambda>null96     fun moveCursorLeft() = apply {
97         if (isLtr()) {
98             moveCursorPrev()
99         } else {
100             moveCursorNext()
101         }
102     }
103 
<lambda>null104     fun moveCursorRight() = apply {
105         if (isLtr()) {
106             moveCursorNext()
107         } else {
108             moveCursorPrev()
109         }
110     }
111 
112     /** If there is already a selection, collapse it to the left side. Otherwise, execute [or] */
<lambda>null113     fun collapseLeftOr(or: T.() -> Unit) = apply {
114         if (selection.collapsed) {
115             @Suppress("UNCHECKED_CAST") or(this as T)
116         } else {
117             if (isLtr()) {
118                 setCursor(selection.min)
119             } else {
120                 setCursor(selection.max)
121             }
122         }
123     }
124 
125     /** If there is already a selection, collapse it to the right side. Otherwise, execute [or] */
<lambda>null126     fun collapseRightOr(or: T.() -> Unit) = apply {
127         if (selection.collapsed) {
128             @Suppress("UNCHECKED_CAST") or(this as T)
129         } else {
130             if (isLtr()) {
131                 setCursor(selection.max)
132             } else {
133                 setCursor(selection.min)
134             }
135         }
136     }
137 
138     /**
139      * Returns the index of the code point preceding the end of [selection], or [NoCharacterFound]
140      * if there is no preceding code point. If the character is within an emoji, it returns the
141      * start of the emoji instead.
142      */
getPrecedingCodePointOrEmojiStartIndexnull143     fun getPrecedingCodePointOrEmojiStartIndex() =
144         annotatedString.text.findCodePointOrEmojiStartBefore(
145             index = selection.end,
146             ifNotFound = NoCharacterFound
147         )
148 
149     /** Returns the index of the character break preceding the end of [selection]. */
150     fun getPrecedingCharacterIndex() = annotatedString.text.findPrecedingBreak(selection.end)
151 
152     /**
153      * Returns the index of the character break following the end of [selection]. Returns
154      * [NoCharacterFound] if there are no more breaks before the end of the string.
155      */
156     fun getNextCharacterIndex() = annotatedString.text.findFollowingBreak(selection.end)
157 
158     private fun moveCursorPrev() = apply {
159         val prev = getPrecedingCharacterIndex()
160         if (prev != -1) setCursor(prev)
161     }
162 
<lambda>null163     private fun moveCursorNext() = apply {
164         val next = getNextCharacterIndex()
165         if (next != -1) setCursor(next)
166     }
167 
<lambda>null168     fun moveCursorToHome() = apply { setCursor(0) }
169 
<lambda>null170     fun moveCursorToEnd() = apply { setCursor(text.length) }
171 
<lambda>null172     fun moveCursorLeftByWord() = apply {
173         if (isLtr()) {
174             moveCursorPrevByWord()
175         } else {
176             moveCursorNextByWord()
177         }
178     }
179 
<lambda>null180     fun moveCursorRightByWord() = apply {
181         if (isLtr()) {
182             moveCursorNextByWord()
183         } else {
184             moveCursorPrevByWord()
185         }
186     }
187 
getNextWordOffsetnull188     fun getNextWordOffset(): Int? = layoutResult?.getNextWordOffsetForLayout()
189 
190     private fun moveCursorNextByWord() = apply { getNextWordOffset()?.let { setCursor(it) } }
191 
getPreviousWordOffsetnull192     fun getPreviousWordOffset(): Int? = layoutResult?.getPrevWordOffset()
193 
194     private fun moveCursorPrevByWord() = apply { getPreviousWordOffset()?.let { setCursor(it) } }
195 
<lambda>null196     fun moveCursorPrevByParagraph() = apply {
197         var paragraphStart = text.findParagraphStart(selection.min)
198         if (paragraphStart == selection.min && paragraphStart != 0) {
199             paragraphStart = text.findParagraphStart(paragraphStart - 1)
200         }
201         setCursor(paragraphStart)
202     }
203 
<lambda>null204     fun moveCursorNextByParagraph() = apply {
205         var paragraphEnd = text.findParagraphEnd(selection.max)
206         if (paragraphEnd == selection.max && paragraphEnd != text.length) {
207             paragraphEnd = text.findParagraphEnd(paragraphEnd + 1)
208         }
209         setCursor(paragraphEnd)
210     }
211 
moveCursorUpByLinenull212     fun moveCursorUpByLine() =
213         apply(false) { layoutResult?.jumpByLinesOffset(-1)?.let { setCursor(it) } }
214 
moveCursorDownByLinenull215     fun moveCursorDownByLine() =
216         apply(false) { layoutResult?.jumpByLinesOffset(1)?.let { setCursor(it) } }
217 
getLineStartByOffsetnull218     fun getLineStartByOffset(): Int? = layoutResult?.getLineStartByOffsetForLayout()
219 
220     fun moveCursorToLineStart() = apply { getLineStartByOffset()?.let { setCursor(it) } }
221 
getLineEndByOffsetnull222     fun getLineEndByOffset(): Int? = layoutResult?.getLineEndByOffsetForLayout()
223 
224     fun moveCursorToLineEnd() = apply { getLineEndByOffset()?.let { setCursor(it) } }
225 
<lambda>null226     fun moveCursorToLineLeftSide() = apply {
227         if (isLtr()) {
228             moveCursorToLineStart()
229         } else {
230             moveCursorToLineEnd()
231         }
232     }
233 
<lambda>null234     fun moveCursorToLineRightSide() = apply {
235         if (isLtr()) {
236             moveCursorToLineEnd()
237         } else {
238             moveCursorToLineStart()
239         }
240     }
241 
242     // it selects a text from the original selection start to a current selection end
selectMovementnull243     fun selectMovement() =
244         apply(false) { selection = TextRange(originalSelection.start, selection.end) }
245 
isLtrnull246     private fun isLtr(): Boolean {
247         val direction = layoutResult?.getParagraphDirection(transformedEndOffset())
248         return direction != ResolvedTextDirection.Rtl
249     }
250 
getNextWordOffsetForLayoutnull251     private tailrec fun TextLayoutResult.getNextWordOffsetForLayout(
252         currentOffset: Int = transformedEndOffset()
253     ): Int {
254         if (currentOffset >= originalText.length) {
255             return originalText.length
256         }
257         val currentWord = getWordBoundary(charOffset(currentOffset))
258         return if (currentWord.end <= currentOffset) {
259             getNextWordOffsetForLayout(currentOffset + 1)
260         } else {
261             offsetMapping.transformedToOriginal(currentWord.end)
262         }
263     }
264 
getPrevWordOffsetnull265     private tailrec fun TextLayoutResult.getPrevWordOffset(
266         currentOffset: Int = transformedEndOffset()
267     ): Int {
268         if (currentOffset <= 0) {
269             return 0
270         }
271         val currentWord = getWordBoundary(charOffset(currentOffset))
272         return if (currentWord.start >= currentOffset) {
273             getPrevWordOffset(currentOffset - 1)
274         } else {
275             offsetMapping.transformedToOriginal(currentWord.start)
276         }
277     }
278 
getLineStartByOffsetForLayoutnull279     private fun TextLayoutResult.getLineStartByOffsetForLayout(
280         currentOffset: Int = transformedMinOffset()
281     ): Int {
282         val currentLine = getLineForOffset(currentOffset)
283         return offsetMapping.transformedToOriginal(getLineStart(currentLine))
284     }
285 
getLineEndByOffsetForLayoutnull286     private fun TextLayoutResult.getLineEndByOffsetForLayout(
287         currentOffset: Int = transformedMaxOffset()
288     ): Int {
289         val currentLine = getLineForOffset(currentOffset)
290         return offsetMapping.transformedToOriginal(getLineEnd(currentLine, true))
291     }
292 
jumpByLinesOffsetnull293     private fun TextLayoutResult.jumpByLinesOffset(linesAmount: Int): Int {
294         val currentOffset = transformedEndOffset()
295 
296         if (state.cachedX == null) {
297             state.cachedX = getCursorRect(currentOffset).left
298         }
299 
300         val targetLine = getLineForOffset(currentOffset) + linesAmount
301         when {
302             targetLine < 0 -> {
303                 return 0
304             }
305             targetLine >= lineCount -> {
306                 return text.length
307             }
308         }
309 
310         val y = getLineBottom(targetLine) - 1
311         val x =
312             state.cachedX!!.also {
313                 if (
314                     (isLtr() && it >= getLineRight(targetLine)) ||
315                         (!isLtr() && it <= getLineLeft(targetLine))
316                 ) {
317                     return getLineEnd(targetLine, true)
318                 }
319             }
320 
321         val newOffset =
322             getOffsetForPosition(Offset(x, y)).let { offsetMapping.transformedToOriginal(it) }
323 
324         return newOffset
325     }
326 
transformedEndOffsetnull327     private fun transformedEndOffset(): Int {
328         return offsetMapping.originalToTransformed(selection.end)
329     }
330 
transformedMinOffsetnull331     private fun transformedMinOffset(): Int {
332         return offsetMapping.originalToTransformed(selection.min)
333     }
334 
transformedMaxOffsetnull335     private fun transformedMaxOffset(): Int {
336         return offsetMapping.originalToTransformed(selection.max)
337     }
338 
charOffsetnull339     private fun charOffset(offset: Int) = offset.coerceAtMost(text.length - 1)
340 
341     companion object {
342         /**
343          * Value returned by [getNextCharacterIndex] and [getPrecedingCharacterIndex] when no valid
344          * index could be found, e.g. it would be the end of the string.
345          *
346          * This is equivalent to `BreakIterator.DONE` on JVM/Android.
347          */
348         const val NoCharacterFound = -1
349     }
350 }
351 
352 internal class TextPreparedSelection(
353     originalText: AnnotatedString,
354     originalSelection: TextRange,
355     layoutResult: TextLayoutResult? = null,
356     offsetMapping: OffsetMapping = OffsetMapping.Identity,
357     state: TextPreparedSelectionState = TextPreparedSelectionState()
358 ) :
359     BaseTextPreparedSelection<TextPreparedSelection>(
360         originalText = originalText,
361         originalSelection = originalSelection,
362         layoutResult = layoutResult,
363         offsetMapping = offsetMapping,
364         state = state
365     )
366 
367 internal class TextFieldPreparedSelection(
368     val currentValue: TextFieldValue,
369     offsetMapping: OffsetMapping = OffsetMapping.Identity,
370     val layoutResultProxy: TextLayoutResultProxy?,
371     state: TextPreparedSelectionState = TextPreparedSelectionState()
372 ) :
373     BaseTextPreparedSelection<TextFieldPreparedSelection>(
374         originalText = currentValue.annotatedString,
375         originalSelection = currentValue.selection,
376         offsetMapping = offsetMapping,
377         layoutResult = layoutResultProxy?.value,
378         state = state
379     ) {
380     val value
381         get() = currentValue.copy(annotatedString = annotatedString, selection = selection)
382 
deleteIfSelectedOrnull383     fun deleteIfSelectedOr(or: TextFieldPreparedSelection.() -> EditCommand?): List<EditCommand>? {
384         return if (selection.collapsed) {
385             or(this)?.let { listOf(it) }
386         } else {
387             listOf(CommitTextCommand("", 0), SetSelectionCommand(selection.min, selection.min))
388         }
389     }
390 
moveCursorUpByPagenull391     fun moveCursorUpByPage() =
392         apply(false) { layoutResultProxy?.jumpByPagesOffset(-1)?.let { setCursor(it) } }
393 
moveCursorDownByPagenull394     fun moveCursorDownByPage() =
395         apply(false) { layoutResultProxy?.jumpByPagesOffset(1)?.let { setCursor(it) } }
396 
397     /**
398      * Returns a cursor position after jumping back or forth by [pagesAmount] number of pages, where
399      * `page` is the visible amount of space in the text field
400      */
TextLayoutResultProxynull401     private fun TextLayoutResultProxy.jumpByPagesOffset(pagesAmount: Int): Int {
402         val visibleInnerTextFieldRect =
403             innerTextFieldCoordinates?.let { inner ->
404                 decorationBoxCoordinates?.localBoundingBoxOf(inner)
405             } ?: Rect.Zero
406         val currentOffset = offsetMapping.originalToTransformed(currentValue.selection.end)
407         val currentPos = value.getCursorRect(currentOffset)
408         val x = currentPos.left
409         val y = currentPos.top + visibleInnerTextFieldRect.size.height * pagesAmount
410         return offsetMapping.transformedToOriginal(value.getOffsetForPosition(Offset(x, y)))
411     }
412 }
413