All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.text.selection.SelectionAdjustment.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

There is a newer version: 1.8.0-alpha01
Show newest version
/*
 * 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.findFollowingBreak
import androidx.compose.foundation.text.findPrecedingBreak
import androidx.compose.foundation.text.getParagraphBoundary
import androidx.compose.ui.text.TextRange

/**
 * Selection can be adjusted depends on context. For example, in touch mode dragging after a long
 * press adjusts selection by word. But selection by dragging handles is character precise
 * without adjustments. With a mouse, double-click selects by words and triple-clicks by paragraph.
 * @see [SelectionRegistrar.notifySelectionUpdate]
 */
internal fun interface SelectionAdjustment {

    /**
     * The callback function that is called once a new selection arrives, the return value of
     * this function will be the final adjusted [Selection].
     */
    fun adjust(layout: SelectionLayout): Selection

    companion object {
        /**
         * The selection adjustment that does nothing and directly return the input raw
         * selection range.
         */
        val None = SelectionAdjustment { layout ->
            Selection(
                start = layout.startInfo.anchorForOffset(layout.startInfo.rawStartHandleOffset),
                end = layout.endInfo.anchorForOffset(layout.endInfo.rawEndHandleOffset),
                handlesCrossed = layout.crossStatus == CrossStatus.CROSSED
            )
        }

        /**
         * The character based selection. It normally won't change the raw selection range except
         * when the input raw selection range is collapsed. In this case, it will almost
         * always make sure at least one character is selected.
         */
        val Character = SelectionAdjustment { layout ->
            None.adjust(layout).ensureAtLeastOneChar(layout)
        }

        /**
         * The word based selection adjustment. It will adjust the raw input selection such that
         * the selection boundary snap to the word boundary. It will always expand the raw input
         * selection range to the closest word boundary. If the raw selection is reversed, it
         * will always return a reversed selection, and vice versa.
         */
        val Word = SelectionAdjustment { layout ->
            adjustToBoundaries(layout) {
                textLayoutResult.getWordBoundary(it)
            }
        }

        /**
         * The paragraph based selection adjustment. It will adjust the raw input selection such
         * that the selection boundary snap to the paragraph boundary. It will always expand the
         * raw input selection range to the closest paragraph boundary. If the raw selection is
         * reversed, it will always return a reversed selection, and vice versa.
         */
        val Paragraph = SelectionAdjustment { layout ->
            adjustToBoundaries(layout) {
                inputText.getParagraphBoundary(it)
            }
        }

        /**
         * A special version of character based selection that accelerates the selection update
         * with word based selection. In short, it expands by word and shrinks by character.
         * Here is more details of the behavior:
         * 1. When previous selection is null, it will use word based selection.
         * 2. When the start/end offset has moved to a different line/Text, it will use word
         * based selection.
         * 3. When the selection is shrinking, it behave same as the character based selection.
         * Shrinking means that the start/end offset is moving in the direction that makes
         * selected text shorter.
         * 4. The selection boundary is expanding,
         *  a.if the previous start/end offset is not a word boundary, use character based
         * selection.
         *  b.if the previous start/end offset is a word boundary, use word based selection.
         *
         *  Notice that this selection adjustment assumes that when isStartHandle is true, only
         *  start handle is moving(or unchanged), and vice versa.
         */
        val CharacterWithWordAccelerate = SelectionAdjustment { layout ->
            val previousSelection = layout.previousSelection
                ?: return@SelectionAdjustment Word.adjust(layout)

            val previousAnchor: Selection.AnchorInfo
            val newAnchor: Selection.AnchorInfo
            val startAnchor: Selection.AnchorInfo
            val endAnchor: Selection.AnchorInfo

            if (layout.isStartHandle) {
                previousAnchor = previousSelection.start
                newAnchor = layout.updateSelectionBoundary(layout.startInfo, previousAnchor)
                startAnchor = newAnchor
                endAnchor = previousSelection.end
            } else {
                previousAnchor = previousSelection.end
                newAnchor = layout.updateSelectionBoundary(layout.endInfo, previousAnchor)
                startAnchor = previousSelection.start
                endAnchor = newAnchor
            }

            if (newAnchor == previousAnchor) {
                // This avoids some cases in BiDi where `layout.crossed` is incorrect.
                // In BiDi layout, a single character move gesture can result in the offset
                // changing a large amount when crossing over from LTR -> RTL or visa versa.
                // This can result in a layout which says it is crossed, but our new selection
                // is uncrossed. Instead, just re-use the old selection.
                // It also saves an allocation.
                previousSelection
            } else {
                val crossed = layout.crossStatus == CrossStatus.CROSSED ||
                    (layout.crossStatus == CrossStatus.COLLAPSED &&
                        startAnchor.offset > endAnchor.offset)
                Selection(startAnchor, endAnchor, crossed).ensureAtLeastOneChar(layout)
            }
        }
    }
}

/**
 * @receiver The selection layout. It is expected that its previousSelection is non-null
 */
private fun SelectionLayout.updateSelectionBoundary(
    info: SelectableInfo,
    previousSelectionAnchor: Selection.AnchorInfo
): Selection.AnchorInfo {
    val currentRawOffset =
        if (isStartHandle) info.rawStartHandleOffset
        else info.rawEndHandleOffset

    val currentSlot = if (isStartHandle) startSlot else endSlot
    if (currentSlot != info.slot) {
        // we are between Texts
        return info.anchorForOffset(currentRawOffset)
    }

    val currentRawLine by lazy(LazyThreadSafetyMode.NONE) {
        info.textLayoutResult.getLineForOffset(currentRawOffset)
    }

    val otherRawOffset =
        if (isStartHandle) info.rawEndHandleOffset
        else info.rawStartHandleOffset

    val anchorSnappedToWordBoundary by lazy(LazyThreadSafetyMode.NONE) {
        info.snapToWordBoundary(
            currentLine = currentRawLine,
            currentOffset = currentRawOffset,
            otherOffset = otherRawOffset,
            isStart = isStartHandle,
            crossed = crossStatus == CrossStatus.CROSSED
        )
    }

    if (info.selectableId != previousSelectionAnchor.selectableId) {
        // moved to an entirely new Text, use word based adjustment
        return anchorSnappedToWordBoundary
    }

    val rawPreviousHandleOffset = info.rawPreviousHandleOffset
    if (currentRawOffset == rawPreviousHandleOffset) {
        // no change in current handle, return the previous result unchanged
        return previousSelectionAnchor
    }

    val previousRawLine = info.textLayoutResult.getLineForOffset(rawPreviousHandleOffset)
    // Check raw lines. The previous adjusted selection offset could remain
    // on a different line after snapping to the word boundary, causing the code to
    // always seem like it is switching lines and never allowing it to not use the
    // word boundary offset.
    if (currentRawLine != previousRawLine) {
        // Line changed, use word based adjustment.
        return anchorSnappedToWordBoundary
    }

    val previousSelectionOffset = previousSelectionAnchor.offset
    val previousSelectionWordBoundary =
        info.textLayoutResult.getWordBoundary(previousSelectionOffset)

    if (!info.isExpanding(currentRawOffset, isStartHandle)) {
        // we're shrinking, use the raw offset.
        return info.anchorForOffset(currentRawOffset)
    }

    if (previousSelectionOffset == previousSelectionWordBoundary.start ||
        previousSelectionOffset == previousSelectionWordBoundary.end
    ) {
        // We are expanding, and the previous offset was a word boundary,
        // so continue using word boundaries.
        return anchorSnappedToWordBoundary
    }

    // We're expanding, but our previousOffset was not at a word boundary. This means
    // we are adjusting a selection within a word already, so continue to do so.
    return info.anchorForOffset(currentRawOffset)
}

private fun SelectableInfo.isExpanding(
    currentRawOffset: Int,
    isStart: Boolean
): Boolean {
    if (rawPreviousHandleOffset == -1) {
        return true
    }
    if (currentRawOffset == rawPreviousHandleOffset) {
        return false
    }

    val crossed = rawCrossStatus == CrossStatus.CROSSED
    return if (isStart xor crossed) {
        currentRawOffset < rawPreviousHandleOffset
    } else {
        currentRawOffset > rawPreviousHandleOffset
    }
}

private fun SelectableInfo.snapToWordBoundary(
    currentLine: Int,
    currentOffset: Int,
    otherOffset: Int,
    isStart: Boolean,
    crossed: Boolean,
): Selection.AnchorInfo {
    val wordBoundary = textLayoutResult.getWordBoundary(currentOffset)

    // In the case where the target word crosses multiple lines due to hyphenation or
    // being too long, we use the line start/end to keep the adjusted offset at the
    // same line.
    val wordStartLine = textLayoutResult.getLineForOffset(wordBoundary.start)
    val start = if (wordStartLine == currentLine) {
        wordBoundary.start
    } else if (currentLine >= textLayoutResult.lineCount) {
        // We cannot find the line start, because this line is not even visible.
        // Since we cannot really select meaningfully in this area,
        // just use the start of the last visible line.
        textLayoutResult.getLineStart(textLayoutResult.lineCount - 1)
    } else {
        textLayoutResult.getLineStart(currentLine)
    }

    val wordEndLine = textLayoutResult.getLineForOffset(wordBoundary.end)
    val end = if (wordEndLine == currentLine) {
        wordBoundary.end
    } else if (currentLine >= textLayoutResult.lineCount) {
        // We cannot find the line end, because this line is not even visible.
        // Since we cannot really select meaningfully in this area,
        // just use the end of the last visible line.
        textLayoutResult.getLineEnd(textLayoutResult.lineCount - 1)
    } else {
        textLayoutResult.getLineEnd(currentLine)
    }

    // If one of the word boundary is exactly same as the otherBoundaryOffset, we
    // can't snap to this word boundary since it will result in an empty selection
    // range.
    if (start == otherOffset) {
        return anchorForOffset(end)
    }
    if (end == otherOffset) {
        return anchorForOffset(start)
    }

    val resultOffset = if (isStart xor crossed) {
        // In this branch when:
        // 1. selection is updating the start offset, and selection is not reversed.
        // 2. selection is updating the end offset, and selection is reversed.
        if (currentOffset <= end) start else end
    } else {
        // In this branch when:
        // 1. selection is updating the end offset, and selection is not reversed.
        // 2. selection is updating the start offset, and selection is reversed.
        if (currentOffset >= start) end else start
    }

    return anchorForOffset(resultOffset)
}

private fun interface BoundaryFunction {
    fun SelectableInfo.getBoundary(offset: Int): TextRange
}

private fun adjustToBoundaries(
    layout: SelectionLayout,
    boundaryFunction: BoundaryFunction,
): Selection {
    val crossed = layout.crossStatus == CrossStatus.CROSSED
    return Selection(
        start = layout.startInfo.anchorOnBoundary(
            crossed = crossed,
            isStart = true,
            slot = layout.startSlot,
            boundaryFunction = boundaryFunction,
        ),
        end = layout.endInfo.anchorOnBoundary(
            crossed = crossed,
            isStart = false,
            slot = layout.endSlot,
            boundaryFunction = boundaryFunction,
        ),
        handlesCrossed = crossed
    )
}

private fun SelectableInfo.anchorOnBoundary(
    crossed: Boolean,
    isStart: Boolean,
    slot: Int,
    boundaryFunction: BoundaryFunction,
): Selection.AnchorInfo {
    val offset = if (isStart) rawStartHandleOffset else rawEndHandleOffset

    if (slot != this.slot) {
        return anchorForOffset(offset)
    }

    val range = with(boundaryFunction) {
        getBoundary(offset)
    }

    return anchorForOffset(if (isStart xor crossed) range.start else range.end)
}

/**
 * This method adjusts the selection to one character respecting [String.findPrecedingBreak]
 * and [String.findFollowingBreak].
 */
internal fun Selection.ensureAtLeastOneChar(layout: SelectionLayout): Selection {
    // There already is at least one char in this selection, return this selection unchanged.
    if (!isCollapsed(layout)) {
        return this
    }

    // Exceptions where 0 char selection is acceptable:
    //   - The selection crosses multiple Texts, but is still collapsed.
    //       - In the same situation in a single Text, we usually select some whitespace.
    //         Since there is no whitespace to select, select nothing. Expanding the selection
    //         into any Texts in this case is likely confusing to the user
    //         as it is different functionality compared to single text.
    //   - The previous selection is null, indicating this is the start of a selection.
    //       - This allows a selection to start off as collapsed. This is necessary for
    //         Character adjustment to allow an initial collapsed selection, and then once a
    //         non-collapsed selection is started, this exception goes away.
    //   - There is no text to select at all, so you can't expand anywhere.
    val text = layout.currentInfo.inputText
    if (layout.size > 1 || layout.previousSelection == null || text.isEmpty()) {
        return this
    }

    return expandOneChar(layout)
}

/**
 * Precondition: the selection is empty.
 */
private fun Selection.expandOneChar(layout: SelectionLayout): Selection {
    val info = layout.currentInfo
    val text = info.inputText
    val offset = info.rawStartHandleOffset // start and end are the same, so either works

    // when the offset is at either boundary of the text,
    // expand the current handle one character into the text from the boundary.
    val lastOffset = text.length
    return when (offset) {
        0 -> {
            val followingBreak = text.findFollowingBreak(0)
            if (layout.isStartHandle) {
                copy(start = start.changeOffset(info, followingBreak), handlesCrossed = true)
            } else {
                copy(end = end.changeOffset(info, followingBreak), handlesCrossed = false)
            }
        }

        lastOffset -> {
            val precedingBreak = text.findPrecedingBreak(lastOffset)
            if (layout.isStartHandle) {
                copy(start = start.changeOffset(info, precedingBreak), handlesCrossed = false)
            } else {
                copy(end = end.changeOffset(info, precedingBreak), handlesCrossed = true)
            }
        }

        else -> {
            // In cases where offset is not along the boundary,
            // we will try to maintain the current cross handle states.
            val crossed = layout.previousSelection?.handlesCrossed == true
            val newOffset =
                if (layout.isStartHandle xor crossed) {
                    text.findPrecedingBreak(offset)
                } else {
                    text.findFollowingBreak(offset)
                }

            if (layout.isStartHandle) {
                copy(start = start.changeOffset(info, newOffset), handlesCrossed = crossed)
            } else {
                copy(end = end.changeOffset(info, newOffset), handlesCrossed = crossed)
            }
        }
    }
}

// update direction when we are changing the offset since it may be different
private fun Selection.AnchorInfo.changeOffset(
    info: SelectableInfo,
    newOffset: Int,
): Selection.AnchorInfo = copy(
    offset = newOffset,
    direction = info.textLayoutResult.getBidiRunDirection(newOffset)
)




© 2015 - 2025 Weber Informatics LLC | Privacy Policy