TextPreparedSelection.kt

/*
 * Copyright 2021 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.text.selection

import androidx.compose.foundation.text.TextLayoutResultProxy
import androidx.compose.foundation.text.findFollowingBreak
import androidx.compose.foundation.text.findPrecedingBreak
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.ResolvedTextDirection
import kotlin.math.max
import kotlin.math.min

/**
 * 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]
 */
internal abstract class BaseTextPreparedSelection<T : BaseTextPreparedSelection<T>>(
    val originalText: AnnotatedString,
    val originalSelection: TextRange,
    val layoutResult: TextLayoutResult?,
    val offsetMapping: OffsetMapping
) {
    var selection = originalSelection

    var annotatedString = originalText
    protected val text
        get() = annotatedString.text

    @Suppress("UNCHECKED_CAST")
    inline fun <U> U.apply(block: U.() -> Unit): T {
        block()
        return this as T
    }

    fun setCursor(offset: Int) = apply {
        setSelection(offset, offset)
    }

    fun setSelection(start: Int, end: Int) = apply {
        selection = TextRange(start, end)
    }

    fun selectAll() = apply {
        setSelection(0, text.length)
    }

    fun deselect() = apply {
        setCursor(selection.end)
    }

    fun moveCursorLeft() = apply {
        if (isLtr()) {
            moveCursorPrev()
        } else {
            moveCursorNext()
        }
    }

    fun moveCursorRight() = apply {
        if (isLtr()) {
            moveCursorNext()
        } else {
            moveCursorPrev()
        }
    }

    /**
     * If there is already a selection, collapse it to the left side. Otherwise, execute [or]
     */
    fun collapseLeftOr(or: T.() -> Unit) = apply {
        if (selection.collapsed) {
            @Suppress("UNCHECKED_CAST")
            or(this as T)
        } 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: T.() -> Unit) = apply {
        if (selection.collapsed) {
            @Suppress("UNCHECKED_CAST")
            or(this as T)
        } else {
            if (isLtr()) {
                setCursor(selection.max)
            } else {
                setCursor(selection.min)
            }
        }
    }

    fun moveCursorPrev() = apply {
        val prev = annotatedString.text.findPrecedingBreak(selection.end)
        if (prev != -1) setCursor(prev)
    }

    fun moveCursorNext() = apply {
        val next = annotatedString.text.findFollowingBreak(selection.end)
        if (next != -1) setCursor(next)
    }

    fun moveCursorToHome() = apply {
        setCursor(0)
    }

    fun moveCursorToEnd() = apply {
        setCursor(text.length)
    }

    fun moveCursorLeftByWord() = apply {
        if (isLtr()) {
            moveCursorPrevByWord()
        } else {
            moveCursorNextByWord()
        }
    }

    fun moveCursorRightByWord() = apply {
        if (isLtr()) {
            moveCursorNextByWord()
        } else {
            moveCursorPrevByWord()
        }
    }

    fun moveCursorNextByWord() = apply {
        layoutResult?.getNextWordOffset()?.let { setCursor(it) }
    }

    fun moveCursorPrevByWord() = apply {
        layoutResult?.getPrevWordOffset()?.let { setCursor(it) }
    }

    fun moveCursorPrevByParagraph() = apply {
        setCursor(getParagraphStart())
    }

    fun moveCursorNextByParagraph() = apply {
        setCursor(getParagraphEnd())
    }

    fun moveCursorUpByLine() = apply {
        layoutResult?.jumpByLinesOffset(-1)?.let { setCursor(it) }
    }

    fun moveCursorDownByLine() = apply {
        layoutResult?.jumpByLinesOffset(1)?.let { setCursor(it) }
    }

    fun moveCursorToLineStart() = apply {
        layoutResult?.getLineStartByOffset()?.let { setCursor(it) }
    }

    fun moveCursorToLineEnd() = apply {
        layoutResult?.getLineEndByOffset()?.let { setCursor(it) }
    }

    fun moveCursorToLineLeftSide() = apply {
        if (isLtr()) {
            moveCursorToLineStart()
        } else {
            moveCursorToLineEnd()
        }
    }

    fun moveCursorToLineRightSide() = apply {
        if (isLtr()) {
            moveCursorToLineEnd()
        } else {
            moveCursorToLineStart()
        }
    }

    // it selects a text from the original selection start to a current selection end
    fun selectMovement() = apply {
        selection = TextRange(originalSelection.start, selection.end)
    }

    // delete currently selected text and update [selection] and [annotatedString]
    fun deleteSelected() = apply {
        val maxChars = text.length
        val beforeSelection =
            annotatedString.subSequence(max(0, selection.min - maxChars), selection.min)
        val afterSelection =
            annotatedString.subSequence(selection.max, min(selection.max + maxChars, text.length))
        annotatedString = beforeSelection + afterSelection
        setCursor(selection.min)
    }

    private fun isLtr(): Boolean {
        val direction = layoutResult?.getBidiRunDirection(selection.end)
        return direction != ResolvedTextDirection.Rtl
    }

    private fun TextLayoutResult.getNextWordOffset(
        currentOffset: Int = transformedEndOffset()
    ): Int {
        if (currentOffset >= originalText.length) {
            return originalText.length
        }
        val currentWord = getWordBoundary(charOffset(currentOffset))
        return if (currentWord.end <= currentOffset) {
            getNextWordOffset(currentOffset + 1)
        } else {
            offsetMapping.transformedToOriginal(currentWord.end)
        }
    }

    private fun TextLayoutResult.getPrevWordOffset(
        currentOffset: Int = transformedEndOffset()
    ): Int {
        if (currentOffset < 0) {
            return 0
        }
        val currentWord = getWordBoundary(charOffset(currentOffset))
        return if (currentWord.start >= currentOffset) {
            getPrevWordOffset(currentOffset - 1)
        } else {
            offsetMapping.transformedToOriginal(currentWord.start)
        }
    }

    private fun TextLayoutResult.getLineStartByOffset(
        currentOffset: Int = transformedMinOffset()
    ): Int {
        val currentLine = getLineForOffset(currentOffset)
        return offsetMapping.transformedToOriginal(getLineStart(currentLine))
    }

    private fun TextLayoutResult.getLineEndByOffset(
        currentOffset: Int = transformedMaxOffset()
    ): Int {
        val currentLine = getLineForOffset(currentOffset)
        return offsetMapping.transformedToOriginal(getLineEnd(currentLine, true))
    }

    private fun TextLayoutResult.jumpByLinesOffset(linesAmount: Int): Int {
        val currentOffset = transformedEndOffset()

        val newLine = getLineForOffset(currentOffset) + linesAmount
        when {
            newLine < 0 -> {
                return 0
            }
            newLine >= lineCount -> {
                return text.length
            }
        }

        val y = getLineBottom(newLine) - 1
        val x = getCursorRect(currentOffset).left.also {
            if ((isLtr() && it >= getLineRight(newLine)) ||
                (!isLtr() && it <= getLineLeft(newLine))
            ) {
                return getLineEnd(newLine, true)
            }
        }

        val newOffset = getOffsetForPosition(Offset(x, y)).let {
            offsetMapping.transformedToOriginal(it)
        }

        return newOffset
    }

    private fun transformedEndOffset(): Int {
        return offsetMapping.originalToTransformed(originalSelection.end)
    }

    private fun transformedMinOffset(): Int {
        return offsetMapping.originalToTransformed(originalSelection.min)
    }

    private fun transformedMaxOffset(): Int {
        return offsetMapping.originalToTransformed(originalSelection.max)
    }

    private fun charOffset(offset: Int) =
        offset.coerceAtMost(originalText.length - 1)

    private fun getParagraphStart(): Int {
        var index = selection.min
        if (index > 0 && text[index - 1] == '\n') {
            index--
        }
        while (index > 0) {
            if (text[index - 1] == '\n') {
                return index
            }
            index--
        }
        return 0
    }

    private fun getParagraphEnd(): Int {
        var index = selection.max
        if (text[index] == '\n') {
            index++
        }
        while (index < text.length - 1) {
            if (text[index] == '\n') {
                return index
            }
            index++
        }
        return text.length
    }
}

internal class TextFieldPreparedSelection(
    val currentValue: TextFieldValue,
    offsetMapping: OffsetMapping = OffsetMapping.Identity,
    val layoutResultProxy: TextLayoutResultProxy?
) : BaseTextPreparedSelection<TextFieldPreparedSelection>(
    originalText = currentValue.annotatedString,
    originalSelection = currentValue.selection,
    offsetMapping = offsetMapping,
    layoutResult = layoutResultProxy?.value
) {
    val value
        get() = currentValue.copy(
            annotatedString = annotatedString,
            selection = selection
        )

    fun deleteIfSelectedOr(or: TextFieldPreparedSelection.() -> Unit) = apply {
        if (selection.collapsed) {
            or(this)
        } else {
            deleteSelected()
        }
    }

    fun moveCursorUpByPage() = apply {
        layoutResultProxy?.jumpByPagesOffset(-1)?.let { setCursor(it) }
    }

    fun moveCursorDownByPage() = apply {
        layoutResultProxy?.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
     */
    private fun TextLayoutResultProxy.jumpByPagesOffset(pagesAmount: Int): Int {
        val visibleInnerTextFieldRect = innerTextFieldCoordinates?.let { inner ->
            decorationBoxCoordinates?.localBoundingBoxOf(inner)
        } ?: Rect.Zero
        val currentOffset = offsetMapping.originalToTransformed(currentValue.selection.end)
        val currentPos = value.getCursorRect(currentOffset)
        val x = currentPos.left
        val y = currentPos.top + visibleInnerTextFieldRect.size.height * pagesAmount
        return offsetMapping.transformedToOriginal(
            value.getOffsetForPosition(Offset(x, y))
        )
    }
}