SelectionController.kt

/*
 * 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.modifiers

import androidx.compose.foundation.text.TextDragObserver
import androidx.compose.foundation.text.detectDragGesturesAfterLongPressWithObserver
import androidx.compose.foundation.text.isInTouchMode
import androidx.compose.foundation.text.selection.MouseSelectionObserver
import androidx.compose.foundation.text.selection.MultiWidgetSelectionDelegate
import androidx.compose.foundation.text.selection.Selectable
import androidx.compose.foundation.text.selection.SelectionAdjustment
import androidx.compose.foundation.text.selection.SelectionRegistrar
import androidx.compose.foundation.text.selection.hasSelection
import androidx.compose.foundation.text.selection.mouseSelectionDetector
import androidx.compose.foundation.text.textPointerHoverIcon
import androidx.compose.foundation.text.textPointerIcon
import androidx.compose.runtime.RememberObserver
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.text.TextLayoutResult

internal data class StaticTextSelectionParams(
    val layoutCoordinates: LayoutCoordinates?,
    val textLayoutResult: TextLayoutResult?
) {
    companion object {
        val Empty = StaticTextSelectionParams(null, null)
    }
}

/**
 * Holder for selection modifiers while we wait for pointerInput to be ported to new modifiers.
 */
// This is _basically_ a Modifier.Node but moved into remember because we need to do pointerInput
internal class SelectionController(
    private val selectionRegistrar: SelectionRegistrar,
    private val backgroundSelectionColor: Color
) : RememberObserver {
    private var selectable: Selectable? = null
    private val selectableId = selectionRegistrar.nextSelectableId()
    // TODO: Move these into Modifer.element eventually
    private var params: StaticTextSelectionParams = StaticTextSelectionParams.Empty

    val modifier: Modifier = selectionRegistrar.makeSelectionModifier(
        selectableId = selectableId,
        layoutCoordinates = { params.layoutCoordinates },
        textLayoutResult = { params.textLayoutResult },
        isInTouchMode = isInTouchMode
    ).textPointerHoverIcon(selectionRegistrar)

    override fun onRemembered() {
        selectable = selectionRegistrar.subscribe(
            MultiWidgetSelectionDelegate(
                selectableId = selectableId,
                coordinatesCallback = { params.layoutCoordinates },
                layoutResultCallback = { params.textLayoutResult }
            )
        )
    }

    override fun onForgotten() {
        val localSelectable = selectable
        if (localSelectable != null) {
            selectionRegistrar.unsubscribe(localSelectable)
            selectable = null
        }
    }

    override fun onAbandoned() {
        val localSelectable = selectable
        if (localSelectable != null) {
            selectionRegistrar.unsubscribe(localSelectable)
            selectable = null
        }
    }

    fun updateTextLayout(textLayoutResult: TextLayoutResult) {
        params = params.copy(textLayoutResult = textLayoutResult)
    }

    fun updateGlobalPosition(coordinates: LayoutCoordinates) {
        params = params.copy(layoutCoordinates = coordinates)
    }

    fun draw(contentDrawScope: ContentDrawScope) {
        val layoutResult = params.textLayoutResult ?: return
        val selection = selectionRegistrar.subselections[selectableId]

        if (selection != null) {
            val start = if (!selection.handlesCrossed) {
                selection.start.offset
            } else {
                selection.end.offset
            }
            val end = if (!selection.handlesCrossed) {
                selection.end.offset
            } else {
                selection.start.offset
            }

            if (start != end) {
                val selectionPath = layoutResult.multiParagraph.getPathForRange(start, end)
                with(contentDrawScope) {
                    drawPath(selectionPath, backgroundSelectionColor)
                }
            }
        }
    }
}

// this is not chained, but is a standalone factory
@Suppress("ModifierFactoryExtensionFunction")
private fun SelectionRegistrar.makeSelectionModifier(
    selectableId: Long,
    layoutCoordinates: () -> LayoutCoordinates?,
    textLayoutResult: () -> TextLayoutResult?,
    isInTouchMode: Boolean
): Modifier {
    return if (isInTouchMode) {
        val longPressDragObserver = object : TextDragObserver {
            /**
             * The beginning position of the drag gesture. Every time a new drag gesture starts, it wil be
             * recalculated.
             */
            var lastPosition = Offset.Zero

            /**
             * The total distance being dragged of the drag gesture. Every time a new drag gesture starts,
             * it will be zeroed out.
             */
            var dragTotalDistance = Offset.Zero

            override fun onDown(point: Offset) {
                // Not supported for long-press-drag.
            }

            override fun onUp() {
                // Nothing to do.
            }

            override fun onStart(startPoint: Offset) {
                layoutCoordinates()?.let {
                    if (!it.isAttached) return

                    if (textLayoutResult().outOfBoundary(startPoint, startPoint)) {
                        notifySelectionUpdateSelectAll(
                            selectableId = selectableId
                        )
                    } else {
                        notifySelectionUpdateStart(
                            layoutCoordinates = it,
                            startPosition = startPoint,
                            adjustment = SelectionAdjustment.Word
                        )
                    }

                    lastPosition = startPoint
                }
                // selection never started
                if (!hasSelection(selectableId)) return
                // Zero out the total distance that being dragged.
                dragTotalDistance = Offset.Zero
            }

            override fun onDrag(delta: Offset) {
                layoutCoordinates()?.let {
                    if (!it.isAttached) return
                    // selection never started, did not consume any drag
                    if (!hasSelection(selectableId)) return

                    dragTotalDistance += delta
                    val newPosition = lastPosition + dragTotalDistance

                    if (!textLayoutResult().outOfBoundary(lastPosition, newPosition)) {
                        // Notice that only the end position needs to be updated here.
                        // Start position is left unchanged. This is typically important when
                        // long-press is using SelectionAdjustment.WORD or
                        // SelectionAdjustment.PARAGRAPH that updates the start handle position from
                        // the dragBeginPosition.
                        val consumed = notifySelectionUpdate(
                            layoutCoordinates = it,
                            previousPosition = lastPosition,
                            newPosition = newPosition,
                            isStartHandle = false,
                            adjustment = SelectionAdjustment.CharacterWithWordAccelerate
                        )
                        if (consumed) {
                            lastPosition = newPosition
                            dragTotalDistance = Offset.Zero
                        }
                    }
                }
            }

            override fun onStop() {
                if (hasSelection(selectableId)) {
                    notifySelectionUpdateEnd()
                }
            }

            override fun onCancel() {
                if (hasSelection(selectableId)) {
                    notifySelectionUpdateEnd()
                }
            }
        }
        Modifier.pointerInput(longPressDragObserver) {
            detectDragGesturesAfterLongPressWithObserver(
                longPressDragObserver
            )
        }
    } else {
        val mouseSelectionObserver = object : MouseSelectionObserver {
            var lastPosition = Offset.Zero

            override fun onExtend(downPosition: Offset): Boolean {
                layoutCoordinates()?.let { layoutCoordinates ->
                    if (!layoutCoordinates.isAttached) return false
                    val consumed = notifySelectionUpdate(
                        layoutCoordinates = layoutCoordinates,
                        newPosition = downPosition,
                        previousPosition = lastPosition,
                        isStartHandle = false,
                        adjustment = SelectionAdjustment.None
                    )
                    if (consumed) {
                        lastPosition = downPosition
                    }
                    return hasSelection(selectableId)
                }
                return false
            }

            override fun onExtendDrag(dragPosition: Offset): Boolean {
                layoutCoordinates()?.let { layoutCoordinates ->
                    if (!layoutCoordinates.isAttached) return false
                    if (!hasSelection(selectableId)) return false

                    val consumed = notifySelectionUpdate(
                        layoutCoordinates = layoutCoordinates,
                        newPosition = dragPosition,
                        previousPosition = lastPosition,
                        isStartHandle = false,
                        adjustment = SelectionAdjustment.None
                    )

                    if (consumed) {
                        lastPosition = dragPosition
                    }
                }
                return true
            }

            override fun onStart(
                downPosition: Offset,
                adjustment: SelectionAdjustment
            ): Boolean {
                layoutCoordinates()?.let {
                    if (!it.isAttached) return false

                    notifySelectionUpdateStart(
                        layoutCoordinates = it,
                        startPosition = downPosition,
                        adjustment = adjustment
                    )

                    lastPosition = downPosition
                    return hasSelection(selectableId)
                }

                return false
            }

            override fun onDrag(
                dragPosition: Offset,
                adjustment: SelectionAdjustment
            ): Boolean {
                layoutCoordinates()?.let {
                    if (!it.isAttached) return false
                    if (!hasSelection(selectableId)) return false

                    val consumed = notifySelectionUpdate(
                        layoutCoordinates = it,
                        previousPosition = lastPosition,
                        newPosition = dragPosition,
                        isStartHandle = false,
                        adjustment = adjustment
                    )
                    if (consumed) {
                        lastPosition = dragPosition
                    }
                }
                return true
            }
        }
        Modifier.pointerInput(mouseSelectionObserver) {
            mouseSelectionDetector(mouseSelectionObserver)
        }.pointerHoverIcon(textPointerIcon)
    }
}

private fun TextLayoutResult?.outOfBoundary(start: Offset, end: Offset): Boolean {
    this ?: return false

    val lastOffset = layoutInput.text.text.length
    val rawStartOffset = getOffsetForPosition(start)
    val rawEndOffset = getOffsetForPosition(end)

    return rawStartOffset >= lastOffset - 1 && rawEndOffset >= lastOffset - 1 ||
        rawStartOffset < 0 && rawEndOffset < 0
}