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

commonMain.androidx.compose.foundation.text.selection.MultiWidgetSelectionDelegate.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.7.1
Show newest version
/*
 * 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.text.selection

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import kotlin.jvm.Synchronized
import kotlin.math.max

internal class MultiWidgetSelectionDelegate(
    override val selectableId: Long,
    private val coordinatesCallback: () -> LayoutCoordinates?,
    private val layoutResultCallback: () -> TextLayoutResult?
) : Selectable {

    private var _previousTextLayoutResult: TextLayoutResult? = null

    // previously calculated `lastVisibleOffset` for the `_previousTextLayoutResult`
    private var _previousLastVisibleOffset: Int = -1

    /**
     * TextLayoutResult is not expected to change repeatedly in a BasicText composable. At least
     * most TextLayoutResult changes would likely affect Selection logic in some way. Therefore,
     * this value only caches the last visible offset calculation for the latest seen
     * TextLayoutResult instance. Object equality check is not worth the extra calculation as
     * instance check is enough to accomplish whether a text layout has changed in a meaningful
     * way.
     */
    private val TextLayoutResult.lastVisibleOffset: Int
        @Synchronized get() {
            if (_previousTextLayoutResult !== this) {
                val lastVisibleLine = when {
                    !didOverflowHeight || multiParagraph.didExceedMaxLines -> lineCount - 1
                    else -> { // size.height < multiParagraph.height
                        var finalVisibleLine = getLineForVerticalPosition(size.height.toFloat())
                            .coerceAtMost(lineCount - 1)
                        // if final visible line's top is equal to or larger than text layout
                        // result's height, we need to check above lines one by one until we find
                        // a line that fits in boundaries.
                        while (
                            finalVisibleLine >= 0 &&
                            getLineTop(finalVisibleLine) >= size.height
                        ) finalVisibleLine--
                        finalVisibleLine.coerceAtLeast(0)
                    }
                }
                _previousLastVisibleOffset = getLineEnd(lastVisibleLine, true)
                _previousTextLayoutResult = this
            }
            return _previousLastVisibleOffset
        }

    override fun appendSelectableInfoToBuilder(builder: SelectionLayoutBuilder) {
        val layoutCoordinates = getLayoutCoordinates() ?: return
        val textLayoutResult = layoutResultCallback() ?: return

        val relativePosition =
            builder.containerCoordinates.localPositionOf(layoutCoordinates, Offset.Zero)
        val localPosition = builder.currentPosition - relativePosition
        val localPreviousHandlePosition = if (builder.previousHandlePosition.isUnspecified) {
            Offset.Unspecified
        } else {
            builder.previousHandlePosition - relativePosition
        }

        builder.appendSelectableInfo(
            textLayoutResult = textLayoutResult,
            localPosition = localPosition,
            previousHandlePosition = localPreviousHandlePosition,
            selectableId = selectableId,
        )
    }

    override fun getSelectAllSelection(): Selection? {
        val textLayoutResult = layoutResultCallback() ?: return null
        val start = 0
        val end = textLayoutResult.layoutInput.text.length

        return Selection(
            start = Selection.AnchorInfo(
                direction = textLayoutResult.getBidiRunDirection(start),
                offset = start,
                selectableId = selectableId
            ),
            end = Selection.AnchorInfo(
                direction = textLayoutResult.getBidiRunDirection(max(end - 1, 0)),
                offset = end,
                selectableId = selectableId
            ),
            handlesCrossed = false
        )
    }

    override fun getHandlePosition(selection: Selection, isStartHandle: Boolean): Offset {
        // Check if the selection handle's selectable is the current selectable.
        if (isStartHandle && selection.start.selectableId != this.selectableId ||
            !isStartHandle && selection.end.selectableId != this.selectableId
        ) {
            return Offset.Unspecified
        }

        if (getLayoutCoordinates() == null) return Offset.Unspecified

        val textLayoutResult = layoutResultCallback() ?: return Offset.Unspecified
        val offset = if (isStartHandle) selection.start.offset else selection.end.offset
        val coercedOffset = offset.coerceIn(0, textLayoutResult.lastVisibleOffset)
        return getSelectionHandleCoordinates(
            textLayoutResult = textLayoutResult,
            offset = coercedOffset,
            isStart = isStartHandle,
            areHandlesCrossed = selection.handlesCrossed
        )
    }

    override fun getLayoutCoordinates(): LayoutCoordinates? {
        val layoutCoordinates = coordinatesCallback()
        if (layoutCoordinates == null || !layoutCoordinates.isAttached) return null
        return layoutCoordinates
    }

    override fun getText(): AnnotatedString {
        val textLayoutResult = layoutResultCallback() ?: return AnnotatedString("")
        return textLayoutResult.layoutInput.text
    }

    override fun getBoundingBox(offset: Int): Rect {
        val textLayoutResult = layoutResultCallback() ?: return Rect.Zero
        val textLength = textLayoutResult.layoutInput.text.length
        if (textLength < 1) return Rect.Zero
        return textLayoutResult.getBoundingBox(
            offset.coerceIn(0, textLength - 1)
        )
    }

    override fun getLineLeft(offset: Int): Float {
        val textLayoutResult = layoutResultCallback() ?: return -1f
        val line = textLayoutResult.getLineForOffset(offset)
        if (line >= textLayoutResult.lineCount) return -1f
        return textLayoutResult.getLineLeft(line)
    }

    override fun getLineRight(offset: Int): Float {
        val textLayoutResult = layoutResultCallback() ?: return -1f
        val line = textLayoutResult.getLineForOffset(offset)
        if (line >= textLayoutResult.lineCount) return -1f
        return textLayoutResult.getLineRight(line)
    }

    override fun getCenterYForOffset(offset: Int): Float {
        val textLayoutResult = layoutResultCallback() ?: return -1f
        val line = textLayoutResult.getLineForOffset(offset)
        if (line >= textLayoutResult.lineCount) return -1f
        val top = textLayoutResult.getLineTop(line)
        val bottom = textLayoutResult.getLineBottom(line)
        return ((bottom - top) / 2) + top
    }

    override fun getRangeOfLineContaining(offset: Int): TextRange {
        val textLayoutResult = layoutResultCallback() ?: return TextRange.Zero
        val visibleTextLength = textLayoutResult.lastVisibleOffset
        if (visibleTextLength < 1) return TextRange.Zero
        val line = textLayoutResult.getLineForOffset(offset.coerceIn(0, visibleTextLength - 1))
        return TextRange(
            start = textLayoutResult.getLineStart(line),
            end = textLayoutResult.getLineEnd(line, visibleEnd = true)
        )
    }

    override fun getLastVisibleOffset(): Int {
        val textLayoutResult = layoutResultCallback() ?: return 0
        return textLayoutResult.lastVisibleOffset
    }

    override fun getLineHeight(offset: Int): Float {
        val textLayoutResult = layoutResultCallback() ?: return 0f
        val textLength = textLayoutResult.layoutInput.text.length
        if (textLength < 1) return 0f
        val line = textLayoutResult.getLineForOffset(offset.coerceIn(0, textLength - 1))
        return textLayoutResult.multiParagraph.getLineHeight(line)
    }
}

/**
 * Appends a [SelectableInfo] to this [SelectionLayoutBuilder].
 *
 * @param textLayoutResult the [TextLayoutResult] for the selectable
 * @param localPosition the position of the current handle if not being dragged
 * or the drag position if it is
 * @param previousHandlePosition the position of the previous handle
 * @param selectableId the selectableId for the selectable
 */
internal fun SelectionLayoutBuilder.appendSelectableInfo(
    textLayoutResult: TextLayoutResult,
    localPosition: Offset,
    previousHandlePosition: Offset,
    selectableId: Long,
) {
    val bounds = Rect(
        0.0f,
        0.0f,
        textLayoutResult.multiParagraph.width.toFloat(),
        textLayoutResult.multiParagraph.height.toFloat()
    )

    val currentXDirection = getXDirection(localPosition, bounds)
    val currentYDirection = getYDirection(localPosition, bounds)

    fun otherDirection(anchor: Selection.AnchorInfo?): Direction = anchor
        ?.let { getDirectionById(it.selectableId, selectableId) }
        ?: resolve2dDirection(currentXDirection, currentYDirection)

    val otherDirection: Direction
    val startXHandleDirection: Direction
    val startYHandleDirection: Direction
    val endXHandleDirection: Direction
    val endYHandleDirection: Direction
    if (isStartHandle) {
        otherDirection = otherDirection(previousSelection?.end)
        startXHandleDirection = currentXDirection
        startYHandleDirection = currentYDirection
        endXHandleDirection = otherDirection
        endYHandleDirection = otherDirection
    } else {
        otherDirection = otherDirection(previousSelection?.start)
        startXHandleDirection = otherDirection
        startYHandleDirection = otherDirection
        endXHandleDirection = currentXDirection
        endYHandleDirection = currentYDirection
    }

    if (!isSelected(resolve2dDirection(currentXDirection, currentYDirection), otherDirection)) {
        return
    }

    val textLength = textLayoutResult.layoutInput.text.length
    val rawStartHandleOffset: Int
    val rawEndHandleOffset: Int
    if (isStartHandle) {
        rawStartHandleOffset = getOffsetForPosition(localPosition, textLayoutResult)
        rawEndHandleOffset = previousSelection?.end
            ?.getPreviousAdjustedOffset(selectableIdOrderingComparator, selectableId, textLength)
            ?: rawStartHandleOffset
    } else {
        rawEndHandleOffset = getOffsetForPosition(localPosition, textLayoutResult)
        rawStartHandleOffset = previousSelection?.start
            ?.getPreviousAdjustedOffset(selectableIdOrderingComparator, selectableId, textLength)
            ?: rawEndHandleOffset
    }

    val rawPreviousHandleOffset = if (previousHandlePosition.isUnspecified) -1 else {
        getOffsetForPosition(previousHandlePosition, textLayoutResult)
    }

    appendInfo(
        selectableId = selectableId,
        rawStartHandleOffset = rawStartHandleOffset,
        startXHandleDirection = startXHandleDirection,
        startYHandleDirection = startYHandleDirection,
        rawEndHandleOffset = rawEndHandleOffset,
        endXHandleDirection = endXHandleDirection,
        endYHandleDirection = endYHandleDirection,
        rawPreviousHandleOffset = rawPreviousHandleOffset,
        textLayoutResult = textLayoutResult,
    )
}

private fun Selection.AnchorInfo.getPreviousAdjustedOffset(
    selectableIdOrderingComparator: Comparator,
    currentSelectableId: Long,
    currentTextLength: Int
): Int {
    val compareResult = selectableIdOrderingComparator.compare(
        this.selectableId,
        currentSelectableId
    )

    return when {
        compareResult < 0 -> 0
        compareResult > 0 -> currentTextLength
        else -> offset
    }
}

private fun getXDirection(position: Offset, bounds: Rect): Direction = when {
    position.x < bounds.left -> Direction.BEFORE
    position.x > bounds.right -> Direction.AFTER
    else -> Direction.ON
}

private fun getYDirection(position: Offset, bounds: Rect): Direction = when {
    position.y < bounds.top -> Direction.BEFORE
    position.y > bounds.bottom -> Direction.AFTER
    else -> Direction.ON
}

private fun SelectionLayoutBuilder.getDirectionById(
    anchorSelectableId: Long,
    currentSelectableId: Long,
): Direction {
    val compareResult = selectableIdOrderingComparator.compare(
        anchorSelectableId,
        currentSelectableId
    )

    return when {
        compareResult < 0 -> Direction.BEFORE
        compareResult > 0 -> Direction.AFTER
        else -> Direction.ON
    }
}

/**
 * Returns true if either of the directions are [Direction.ON]
 * or if the directions are on opposite sides.
 */
private fun isSelected(currentDirection: Direction, otherDirection: Direction): Boolean =
    currentDirection == Direction.ON || currentDirection != otherDirection

// map offsets above/below the text to 0/length respectively
private fun getOffsetForPosition(position: Offset, textLayoutResult: TextLayoutResult): Int = when {
    position.y <= 0f -> 0
    position.y >= textLayoutResult.multiParagraph.height -> textLayoutResult.layoutInput.text.length
    else -> textLayoutResult.getOffsetForPosition(position)
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy