blob: 7c4310a4fa1eced1f65718336602ab430323fc26 [file] [log] [blame]
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.text2.input.internal
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.TextLayoutResultProxy
import androidx.compose.foundation.text.findFollowingBreak
import androidx.compose.foundation.text.findParagraphEnd
import androidx.compose.foundation.text.findParagraphStart
import androidx.compose.foundation.text.findPrecedingBreak
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.style.ResolvedTextDirection
import kotlin.math.abs
/**
* [TextFieldPreparedSelection] provides a scope for many selection-related operations. However,
* some vertical cursor operations like moving between lines or page up and down require a cache of
* X position in text to remember where to move the cursor in next line.
* [TextFieldPreparedSelection] is a disposable scope that cannot hold its own state. This class
* helps to pass a cached X value between selection operations in different scopes.
*/
internal class TextFieldPreparedSelectionState {
/**
* it's set at the start of vertical navigation and used as the preferred value to set a new
* cursor position.
*/
var cachedX: Float? = null
/**
* Remove and forget the cached X used for vertical navigation.
*/
fun resetCachedX() {
cachedX = null
}
}
/**
* This utility class implements many selection-related operations on text (including basic
* cursor movements and deletions) and combines them, taking into account how the text was
* rendered. So, for example, [moveCursorToLineEnd] moves it to the visual line end.
*
* For many of these operations, it's particularly important to keep the difference between
* selection start and selection end. In some systems, they are called "anchor" and "caret"
* respectively. For example, for selection from scratch, after [moveCursorLeftByWord]
* [moveCursorRight] will move the left side of the selection, but after [moveCursorRightByWord]
* the right one.
*
* To use it in scope of text fields see [TextFieldPreparedSelection]
*/
@OptIn(ExperimentalFoundationApi::class)
internal class TextFieldPreparedSelection(
private val state: TextFieldState,
private val textLayoutState: TextLayoutState,
private val textPreparedSelectionState: TextFieldPreparedSelectionState
) {
/**
* Read the value from state without read observation to not accidentally cause recompositions.
* Freezing the initial value is necessary to make atomic operations in the scope of this
* [TextFieldPreparedSelection]. It is also used to make comparison between the initial state
* and the modified state of selection and content.
*/
val initialValue = Snapshot.withoutReadObservation { state.value }
/**
* Current active selection in the context of this [TextFieldPreparedSelection]
*/
var selection = initialValue.selectionInChars
/**
* Initial text value.
*/
private val text: String = initialValue.toString()
/**
* If there is a non-collapsed selection, delete its contents. Or execute the given [or] block.
* Either way this function returns list of [EditCommand]s that should be applied on
* [TextFieldState].
*/
fun deleteIfSelectedOr(or: TextFieldPreparedSelection.() -> EditCommand?): List<EditCommand>? {
return if (selection.collapsed) {
or(this)?.let { editCommand -> listOf(editCommand) }
} else {
listOf(
CommitTextCommand("", 0),
SetSelectionCommand(selection.min, selection.min)
)
}
}
/**
* Executes PageUp key
*/
fun moveCursorUpByPage() = applyIfNotEmpty(false) {
textLayoutState.proxy?.jumpByPagesOffset(-1)?.let { setCursor(it) }
}
/**
* Executes PageDown key
*/
fun moveCursorDownByPage() = applyIfNotEmpty(false) {
textLayoutState.proxy?.jumpByPagesOffset(1)?.let { setCursor(it) }
}
/**
* Returns a cursor position after jumping back or forth by [pagesAmount] number of pages,
* where `page` is the visible amount of space in the text field. Visible rectangle is
* calculated by the coordinates of decoration box around the TextField.
*/
private fun TextLayoutResultProxy.jumpByPagesOffset(pagesAmount: Int): Int {
val visibleInnerTextFieldRect = innerTextFieldCoordinates?.let { inner ->
decorationBoxCoordinates?.localBoundingBoxOf(inner)
} ?: Rect.Zero
val currentOffset = initialValue.selectionInChars.end
val currentPos = value.getCursorRect(currentOffset)
val newPos = currentPos.translate(
translateX = 0f,
translateY = visibleInnerTextFieldRect.size.height * pagesAmount
)
// which line does the new cursor position belong?
val topLine = value.getLineForVerticalPosition(newPos.top)
val lineSeparator = value.getLineBottom(topLine)
return if (abs(newPos.top - lineSeparator) > abs(newPos.bottom - lineSeparator)) {
// most of new cursor is on top line
value.getOffsetForPosition(newPos.topLeft)
} else {
// most of new cursor is on bottom line
value.getOffsetForPosition(newPos.bottomLeft)
}
}
/**
* Only apply the given [block] if the text is not empty.
*
* @param resetCachedX Whether to reset the cachedX parameter in [TextFieldPreparedSelectionState].
*/
inline fun applyIfNotEmpty(
resetCachedX: Boolean = true,
block: TextFieldPreparedSelection.() -> Unit
): TextFieldPreparedSelection {
if (resetCachedX) {
textPreparedSelectionState.resetCachedX()
}
if (text.isNotEmpty()) {
this.block()
}
return this
}
/**
* Sets a collapsed selection at given [offset].
*/
private fun setCursor(offset: Int) {
selection = TextRange(offset, offset)
}
fun selectAll() = applyIfNotEmpty {
selection = TextRange(0, text.length)
}
fun deselect() = applyIfNotEmpty {
setCursor(selection.end)
}
fun moveCursorLeft() = applyIfNotEmpty {
if (isLtr()) {
moveCursorPrev()
} else {
moveCursorNext()
}
}
fun moveCursorRight() = applyIfNotEmpty {
if (isLtr()) {
moveCursorNext()
} else {
moveCursorPrev()
}
}
/**
* If there is already a selection, collapse it to the left side. Otherwise, execute [or]
*/
fun collapseLeftOr(or: TextFieldPreparedSelection.() -> Unit) = applyIfNotEmpty {
if (selection.collapsed) {
or(this)
} else {
if (isLtr()) {
setCursor(selection.min)
} else {
setCursor(selection.max)
}
}
}
/**
* If there is already a selection, collapse it to the right side. Otherwise, execute [or]
*/
fun collapseRightOr(or: TextFieldPreparedSelection.() -> Unit) = applyIfNotEmpty {
if (selection.collapsed) {
or(this)
} else {
if (isLtr()) {
setCursor(selection.max)
} else {
setCursor(selection.min)
}
}
}
/**
* Returns the index of the character break preceding the end of [selection].
*/
fun getPrecedingCharacterIndex() = text.findPrecedingBreak(selection.end)
/**
* Returns the index of the character break following the end of [selection]. Returns
* [NoCharacterFound] if there are no more breaks before the end of the string.
*/
fun getNextCharacterIndex() = text.findFollowingBreak(selection.end)
private fun moveCursorPrev() = applyIfNotEmpty {
val prev = getPrecedingCharacterIndex()
if (prev != -1) setCursor(prev)
}
private fun moveCursorNext() = applyIfNotEmpty {
val next = getNextCharacterIndex()
if (next != -1) setCursor(next)
}
fun moveCursorToHome() = applyIfNotEmpty {
setCursor(0)
}
fun moveCursorToEnd() = applyIfNotEmpty {
setCursor(text.length)
}
fun moveCursorLeftByWord() = applyIfNotEmpty {
if (isLtr()) {
moveCursorPrevByWord()
} else {
moveCursorNextByWord()
}
}
fun moveCursorRightByWord() = applyIfNotEmpty {
if (isLtr()) {
moveCursorNextByWord()
} else {
moveCursorPrevByWord()
}
}
fun getNextWordOffset(): Int? = textLayoutState.layoutResult?.getNextWordOffsetForLayout()
private fun moveCursorNextByWord() = applyIfNotEmpty {
getNextWordOffset()?.let { setCursor(it) }
}
fun getPreviousWordOffset(): Int? = textLayoutState.layoutResult?.getPrevWordOffset()
private fun moveCursorPrevByWord() = applyIfNotEmpty {
getPreviousWordOffset()?.let { setCursor(it) }
}
fun moveCursorPrevByParagraph() = applyIfNotEmpty {
setCursor(getParagraphStart())
}
fun moveCursorNextByParagraph() = applyIfNotEmpty {
setCursor(getParagraphEnd())
}
fun moveCursorUpByLine() = applyIfNotEmpty(false) {
textLayoutState.layoutResult?.jumpByLinesOffset(-1)?.let { setCursor(it) }
}
fun moveCursorDownByLine() = applyIfNotEmpty(false) {
textLayoutState.layoutResult?.jumpByLinesOffset(1)?.let { setCursor(it) }
}
fun getLineStartByOffset(): Int? = textLayoutState.layoutResult?.getLineStartByOffsetForLayout()
fun moveCursorToLineStart() = applyIfNotEmpty {
getLineStartByOffset()?.let { setCursor(it) }
}
fun getLineEndByOffset(): Int? = textLayoutState.layoutResult?.getLineEndByOffsetForLayout()
fun moveCursorToLineEnd() = applyIfNotEmpty {
getLineEndByOffset()?.let { setCursor(it) }
}
fun moveCursorToLineLeftSide() = applyIfNotEmpty {
if (isLtr()) {
moveCursorToLineStart()
} else {
moveCursorToLineEnd()
}
}
fun moveCursorToLineRightSide() = applyIfNotEmpty {
if (isLtr()) {
moveCursorToLineEnd()
} else {
moveCursorToLineStart()
}
}
// it selects a text from the original selection start to a current selection end
fun selectMovement() = applyIfNotEmpty(false) {
selection = TextRange(initialValue.selectionInChars.start, selection.end)
}
private fun isLtr(): Boolean {
val direction = textLayoutState.layoutResult?.getParagraphDirection(selection.end)
return direction != ResolvedTextDirection.Rtl
}
private tailrec fun TextLayoutResult.getNextWordOffsetForLayout(
currentOffset: Int = selection.end
): Int {
if (currentOffset >= initialValue.length) {
return initialValue.length
}
val currentWord = getWordBoundary(charOffset(currentOffset))
return if (currentWord.end <= currentOffset) {
getNextWordOffsetForLayout(currentOffset + 1)
} else {
currentWord.end
}
}
private tailrec fun TextLayoutResult.getPrevWordOffset(
currentOffset: Int = selection.end
): Int {
if (currentOffset <= 0) {
return 0
}
val currentWord = getWordBoundary(charOffset(currentOffset))
return if (currentWord.start >= currentOffset) {
getPrevWordOffset(currentOffset - 1)
} else {
currentWord.start
}
}
private fun TextLayoutResult.getLineStartByOffsetForLayout(
currentOffset: Int = selection.min
): Int {
val currentLine = getLineForOffset(currentOffset)
return getLineStart(currentLine)
}
private fun TextLayoutResult.getLineEndByOffsetForLayout(
currentOffset: Int = selection.max
): Int {
val currentLine = getLineForOffset(currentOffset)
return getLineEnd(currentLine, true)
}
private fun TextLayoutResult.jumpByLinesOffset(linesAmount: Int): Int {
val currentOffset = selection.end
if (textPreparedSelectionState.cachedX == null) {
textPreparedSelectionState.cachedX = getCursorRect(currentOffset).left
}
val targetLine = getLineForOffset(currentOffset) + linesAmount
when {
targetLine < 0 -> {
return 0
}
targetLine >= lineCount -> {
return text.length
}
}
val y = getLineBottom(targetLine) - 1
val x = textPreparedSelectionState.cachedX!!.also {
if ((isLtr() && it >= getLineRight(targetLine)) ||
(!isLtr() && it <= getLineLeft(targetLine))
) {
return getLineEnd(targetLine, true)
}
}
return getOffsetForPosition(Offset(x, y))
}
private fun charOffset(offset: Int) = offset.coerceAtMost(text.length - 1)
private fun getParagraphStart() = text.findParagraphStart(selection.min)
private fun getParagraphEnd() = text.findParagraphEnd(selection.max)
companion object {
/**
* Value returned by [getNextCharacterIndex] and [getPrecedingCharacterIndex] when no valid
* index could be found, e.g. it would be the end of the string.
*
* This is equivalent to `BreakIterator.DONE` on JVM/Android.
*/
const val NoCharacterFound = -1
}
}