MultiWidgetSelectionDelegate.kt

/*
 * Copyright 2020 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.layout.LayoutCoordinates
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import kotlin.math.max

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

    override fun getSelection(
        startPosition: Offset,
        endPosition: Offset,
        containerLayoutCoordinates: LayoutCoordinates,
        adjustment: SelectionAdjustment,
        previousSelection: Selection?,
        isStartHandle: Boolean
    ): Selection? {
        val layoutCoordinates = getLayoutCoordinates() ?: return null
        val textLayoutResult = layoutResultCallback() ?: return null

        val relativePosition = containerLayoutCoordinates.localPositionOf(
            layoutCoordinates, Offset.Zero
        )
        val startPx = startPosition - relativePosition
        val endPx = endPosition - relativePosition

        return getTextSelectionInfo(
            textLayoutResult = textLayoutResult,
            selectionCoordinates = Pair(startPx, endPx),
            selectableId = selectableId,
            adjustment = adjustment,
            previousSelection = previousSelection,
            isStartHandle = isStartHandle
        )
    }

    override fun getSelectAllSelection(): Selection? {
        val textLayoutResult = layoutResultCallback() ?: return null

        return getAssembledSelectionInfo(
            startOffset = 0,
            endOffset = textLayoutResult.layoutInput.text.length,
            handlesCrossed = false,
            selectableId = selectableId,
            textLayoutResult = textLayoutResult
        )
    }

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

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

        val textLayoutResult = layoutResultCallback() ?: return Offset.Zero
        return getSelectionHandleCoordinates(
            textLayoutResult = textLayoutResult,
            offset = if (isStartHandle) selection.start.offset else selection.end.offset,
            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
        return textLayoutResult.getBoundingBox(
            offset.coerceIn(
                0,
                textLayoutResult.layoutInput.text.text.length - 1
            )
        )
    }
}

/**
 * Return information about the current selection in the Text.
 *
 * @param textLayoutResult a result of the text layout.
 * @param selectionCoordinates The positions of the start and end of the selection in Text
 * composable coordinate system.
 *
 * @return [Selection] of the current composable, or null if the composable is not selected.
 */
internal fun getTextSelectionInfo(
    textLayoutResult: TextLayoutResult,
    selectionCoordinates: Pair<Offset, Offset>,
    selectableId: Long,
    adjustment: SelectionAdjustment,
    previousSelection: Selection? = null,
    isStartHandle: Boolean = true
): Selection? {
    val textRange = getTextSelectionRange(textLayoutResult, selectionCoordinates) ?: return null
    val adjustedTextRange = adjustSelection(
        textLayoutResult = textLayoutResult,
        textRange = textRange,
        isStartHandle = isStartHandle,
        previousHandlesCrossed = previousSelection?.handlesCrossed ?: false,
        adjustment = adjustment
    )
    return getAssembledSelectionInfo(
        startOffset = adjustedTextRange.start,
        endOffset = adjustedTextRange.end,
        handlesCrossed = adjustedTextRange.reversed,
        selectableId = selectableId,
        textLayoutResult = textLayoutResult
    )
}

internal fun getTextSelectionRange(
    textLayoutResult: TextLayoutResult,
    selectionCoordinates: Pair<Offset, Offset>
): TextRange? {
    val startPosition = selectionCoordinates.first
    val endPosition = selectionCoordinates.second

    val bounds = Rect(
        0.0f,
        0.0f,
        textLayoutResult.size.width.toFloat(),
        textLayoutResult.size.height.toFloat()
    )

    val lastOffset = textLayoutResult.layoutInput.text.text.length

    val containsWholeSelectionStart =
        bounds.contains(Offset(startPosition.x, startPosition.y))

    val containsWholeSelectionEnd =
        bounds.contains(Offset(endPosition.x, endPosition.y))

    val rawStartOffset =
        if (containsWholeSelectionStart) {
            textLayoutResult.getOffsetForPosition(startPosition).coerceIn(0, lastOffset)
        } else {
            // If the composable is selected, the start offset cannot be -1 for this composable. If the
            // final start offset is still -1, it means this composable is not selected.
            -1
        }
    val rawEndOffset =
        if (containsWholeSelectionEnd) {
            textLayoutResult.getOffsetForPosition(endPosition).coerceIn(0, lastOffset)
        } else {
            // If the composable is selected, the end offset cannot be -1 for this composable. If the
            // final end offset is still -1, it means this composable is not selected.
            -1
        }

    return getRefinedSelectionRange(
        rawStartOffset = rawStartOffset,
        rawEndOffset = rawEndOffset,
        startPosition = startPosition,
        endPosition = endPosition,
        bounds = bounds,
        lastOffset = lastOffset,
    )
}

/**
 * This method refines the selection info by processing the initial raw selection info.
 *
 * @param rawStartOffset unprocessed start offset calculated directly from input position.
 * A negative value of this parameter means that the start handle is not in this selectable.
 * @param rawEndOffset unprocessed end offset calculated directly from input position. A negative
 * value of this parameter means that the start handle is not in this selectable.
 * @param startPosition graphical position of the start of the selection, in composable's
 * coordinates.
 * @param endPosition graphical position of the end of the selection, in composable's coordinates.
 * @param bounds bounds of the current composable
 * @param lastOffset last offset of the text. It's actually the length of the text.
 *
 * @return [Selection] of the current composable, or null if the composable is not selected.
 */
private fun getRefinedSelectionRange(
    rawStartOffset: Int,
    rawEndOffset: Int,
    startPosition: Offset,
    endPosition: Offset,
    bounds: Rect,
    lastOffset: Int
): TextRange? {
    val containsWholeSelectionStart = rawStartOffset >= 0
    val containsWholeSelectionEnd = rawEndOffset >= 0

    val shouldProcessAsSinglecomposable =
        containsWholeSelectionStart && containsWholeSelectionEnd

    return if (shouldProcessAsSinglecomposable) {
        TextRange(rawStartOffset, rawEndOffset)
    } else {
        processCrossComposable(
            startPosition = startPosition,
            endPosition = endPosition,
            rawStartOffset = rawStartOffset,
            rawEndOffset = rawEndOffset,
            lastOffset = lastOffset,
            bounds = bounds,
            containsWholeSelectionStart = containsWholeSelectionStart,
            containsWholeSelectionEnd = containsWholeSelectionEnd
        )
    }
}

/**
 * [Selection] contains a lot of parameters. It looks more clean to assemble an object of this
 * class in a separate method.
 *
 * @param startOffset the final start offset to be returned.
 * @param endOffset the final end offset to be returned.
 * @param handlesCrossed true if the selection handles are crossed
 * @param selectableId the id of the current [Selectable] for which the [Selection] is being
 * calculated
 * @param textLayoutResult a result of the text layout.
 *
 * @return an assembled object of [Selection] using the offered selection info.
 */
private fun getAssembledSelectionInfo(
    startOffset: Int,
    endOffset: Int,
    handlesCrossed: Boolean,
    selectableId: Long,
    textLayoutResult: TextLayoutResult
): Selection {
    return Selection(
        start = Selection.AnchorInfo(
            direction = textLayoutResult.getBidiRunDirection(startOffset),
            offset = startOffset,
            selectableId = selectableId
        ),
        end = Selection.AnchorInfo(
            direction = textLayoutResult.getBidiRunDirection(max(endOffset - 1, 0)),
            offset = endOffset,
            selectableId = selectableId
        ),
        handlesCrossed = handlesCrossed
    )
}