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