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

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

/*
 * 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.foundation.text.DefaultCursorThickness
import androidx.compose.foundation.text.Handle
import androidx.compose.foundation.text.HandleState
import androidx.compose.foundation.text.HandleState.Cursor
import androidx.compose.foundation.text.HandleState.None
import androidx.compose.foundation.text.HandleState.Selection
import androidx.compose.foundation.text.LegacyTextFieldState
import androidx.compose.foundation.text.TextDragObserver
import androidx.compose.foundation.text.UndoManager
import androidx.compose.foundation.text.ValidatingEmptyOffsetMappingIdentity
import androidx.compose.foundation.text.detectDownAndDragGesturesWithObserver
import androidx.compose.foundation.text.getLineHeight
import androidx.compose.foundation.text.isPositionInsideSelection
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.input.getSelectedText
import androidx.compose.ui.text.input.getTextAfterSelection
import androidx.compose.ui.text.input.getTextBeforeSelection
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min

/** A bridge class between user interaction to the text field selection. */
internal class TextFieldSelectionManager(val undoManager: UndoManager? = null) {

    /** The current [OffsetMapping] for text field. */
    internal var offsetMapping: OffsetMapping = ValidatingEmptyOffsetMappingIdentity

    /** Called when the input service updates the values in [TextFieldValue]. */
    internal var onValueChange: (TextFieldValue) -> Unit = {}

    /** The current [LegacyTextFieldState]. */
    internal var state: LegacyTextFieldState? = null

    /**
     * The current [TextFieldValue]. This contains the original text, not the transformed text.
     * Transformed text can be found with [transformedText].
     */
    internal var value: TextFieldValue by mutableStateOf(TextFieldValue())

    /**
     * The current transformed text from the [LegacyTextFieldState]. The original text can be found
     * in [value].
     */
    internal val transformedText
        get() = state?.textDelegate?.text

    /**
     * Visual transformation of the text field's text. Used to check if certain toolbar options are
     * permitted. For example, 'cut' will not be available is it is password transformation.
     */
    internal var visualTransformation: VisualTransformation = VisualTransformation.None

    /** [ClipboardManager] to perform clipboard features. */
    internal var clipboardManager: ClipboardManager? = null

    /** [TextToolbar] to show floating toolbar(post-M) or primary toolbar(pre-M). */
    var textToolbar: TextToolbar? = null

    /** [HapticFeedback] handle to perform haptic feedback. */
    var hapticFeedBack: HapticFeedback? = null

    /** [FocusRequester] used to request focus for the TextField. */
    var focusRequester: FocusRequester? = null

    /** Defines if paste and cut toolbar menu actions should be shown */
    var editable by mutableStateOf(true)

    /** Whether the text field should be selectable at all. */
    var enabled by mutableStateOf(true)

    /**
     * The beginning position of the drag gesture. Every time a new drag gesture starts, it wil be
     * recalculated.
     */
    private var dragBeginPosition = Offset.Zero

    /**
     * The beginning offset of the drag gesture translated into position in text. Every time a new
     * drag gesture starts, it wil be recalculated. Unlike [dragBeginPosition] that is relative to
     * the decoration box, [dragBeginOffsetInText] represents index in text. Essentially, it is
     * equal to `layoutResult.getOffsetForPosition(dragBeginPosition)`.
     */
    private var dragBeginOffsetInText: Int? = null

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

    /**
     * A flag to check if a selection or cursor handle is being dragged, and which handle is being
     * dragged. If this value is non-null, then onPress will not select any text. This value will be
     * set to non-null when either handle is being dragged, and be reset to null when the dragging
     * is stopped.
     */
    var draggingHandle: Handle? by mutableStateOf(null)
        internal set

    /** The current position of a drag, in decoration box coordinates. */
    var currentDragPosition: Offset? by mutableStateOf(null)
        internal set

    /**
     * The previous offset of a drag, before selection adjustments. Only update when a selection
     * layout change has occurred, or set to -1 if a new drag begins.
     */
    internal var previousRawDragOffset: Int = -1

    /**
     * The old [TextFieldValue] before entering the selection mode on long press. Used to exit the
     * selection mode.
     */
    private var oldValue: TextFieldValue = TextFieldValue()

    /** The previous [SelectionLayout] where [SelectionLayout.shouldRecomputeSelection] was true. */
    internal var previousSelectionLayout: SelectionLayout? = null

    /** [TextDragObserver] for long press and drag to select in TextField. */
    internal val touchSelectionObserver =
        object : TextDragObserver {
            override fun onDown(point: Offset) {
                // Not supported for long-press-drag.
            }

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

            override fun onStart(startPoint: Offset) {
                if (!enabled || draggingHandle != null) return
                // While selecting by long-press-dragging, the "end" of the selection is always the
                // one
                // being controlled by the drag.
                draggingHandle = Handle.SelectionEnd
                previousRawDragOffset = -1

                // ensuring that current action mode (selection toolbar) is invalidated
                hideSelectionToolbar()

                // Long Press at the blank area, the cursor should show up at the end of the line.
                if (state?.layoutResult?.isPositionOnText(startPoint) != true) {
                    state?.layoutResult?.let { layoutResult ->
                        val transformedOffset = layoutResult.getOffsetForPosition(startPoint)
                        val offset = offsetMapping.transformedToOriginal(transformedOffset)

                        val newValue =
                            createTextFieldValue(
                                annotatedString = value.annotatedString,
                                selection = TextRange(offset, offset)
                            )

                        enterSelectionMode(showFloatingToolbar = false)
                        hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                        onValueChange(newValue)
                    }
                } else {
                    if (value.text.isEmpty()) return
                    enterSelectionMode(showFloatingToolbar = false)
                    val adjustedStartSelection =
                        updateSelection(
                            // reset selection, otherwise a previous selection may be used
                            // as context for creating the next selection
                            value = value.copy(selection = TextRange.Zero),
                            currentPosition = startPoint,
                            isStartOfSelection = true,
                            isStartHandle = false,
                            adjustment = SelectionAdjustment.Word,
                            isTouchBasedSelection = true,
                        )
                    // For touch, set the begin offset to the adjusted selection.
                    // When char based selection is used, we want to ensure we snap the
                    // beginning offset to the start word boundary of the first selected word.
                    dragBeginOffsetInText = adjustedStartSelection.start
                }

                // don't set selection handle state until drag ends
                setHandleState(None)

                dragBeginPosition = startPoint
                currentDragPosition = dragBeginPosition
                dragTotalDistance = Offset.Zero
            }

            override fun onDrag(delta: Offset) {
                // selection never started, did not consume any drag
                if (!enabled || value.text.isEmpty()) return

                dragTotalDistance += delta
                state?.layoutResult?.let { layoutResult ->
                    currentDragPosition = dragBeginPosition + dragTotalDistance

                    if (
                        dragBeginOffsetInText == null &&
                            !layoutResult.isPositionOnText(currentDragPosition!!)
                    ) {
                        // both start and end of drag is in end padding.
                        val startOffset =
                            offsetMapping.transformedToOriginal(
                                layoutResult.getOffsetForPosition(dragBeginPosition)
                            )

                        val endOffset =
                            offsetMapping.transformedToOriginal(
                                layoutResult.getOffsetForPosition(currentDragPosition!!)
                            )

                        val adjustment =
                            if (startOffset == endOffset) {
                                // start and end is in the same end padding, keep the collapsed
                                // selection
                                SelectionAdjustment.None
                            } else {
                                SelectionAdjustment.Word
                            }

                        updateSelection(
                            value = value,
                            currentPosition = currentDragPosition!!,
                            isStartOfSelection = false,
                            isStartHandle = false,
                            adjustment = adjustment,
                            isTouchBasedSelection = true,
                        )
                    } else {
                        val startOffset =
                            dragBeginOffsetInText
                                ?: layoutResult.getOffsetForPosition(
                                    position = dragBeginPosition,
                                    coerceInVisibleBounds = false
                                )
                        val endOffset =
                            layoutResult.getOffsetForPosition(
                                position = currentDragPosition!!,
                                coerceInVisibleBounds = false
                            )

                        if (dragBeginOffsetInText == null && startOffset == endOffset) {
                            // if we are selecting starting from end padding,
                            // don't start selection until we have and un-collapsed selection.
                            return
                        }

                        updateSelection(
                            value = value,
                            currentPosition = currentDragPosition!!,
                            isStartOfSelection = false,
                            isStartHandle = false,
                            adjustment = SelectionAdjustment.Word,
                            isTouchBasedSelection = true,
                        )
                    }
                }
                updateFloatingToolbar(show = false)
            }

            override fun onStop() = onEnd()

            override fun onCancel() = onEnd()

            private fun onEnd() {
                draggingHandle = null
                currentDragPosition = null
                updateFloatingToolbar(show = true)
                dragBeginOffsetInText = null

                val collapsed = value.selection.collapsed
                setHandleState(if (collapsed) Cursor else Selection)
                state?.showSelectionHandleStart =
                    !collapsed && isSelectionHandleInVisibleBound(isStartHandle = true)
                state?.showSelectionHandleEnd =
                    !collapsed && isSelectionHandleInVisibleBound(isStartHandle = false)
                state?.showCursorHandle =
                    collapsed && isSelectionHandleInVisibleBound(isStartHandle = true)
            }
        }

    internal val mouseSelectionObserver =
        object : MouseSelectionObserver {
            override fun onExtend(downPosition: Offset): Boolean {
                // can't update selection without a layoutResult, so don't consume
                state?.layoutResult ?: return false
                if (!enabled) return false
                previousRawDragOffset = -1
                updateMouseSelection(
                    value = value,
                    currentPosition = downPosition,
                    isStartOfSelection = false,
                    adjustment = SelectionAdjustment.None,
                )
                return true
            }

            override fun onExtendDrag(dragPosition: Offset): Boolean {
                if (!enabled || value.text.isEmpty()) return false
                // can't update selection without a layoutResult, so don't consume
                state?.layoutResult ?: return false

                updateMouseSelection(
                    value = value,
                    currentPosition = dragPosition,
                    isStartOfSelection = false,
                    adjustment = SelectionAdjustment.None,
                )
                return true
            }

            override fun onStart(downPosition: Offset, adjustment: SelectionAdjustment): Boolean {
                if (!enabled || value.text.isEmpty()) return false
                // can't update selection without a layoutResult, so don't consume
                state?.layoutResult ?: return false

                focusRequester?.requestFocus()
                dragBeginPosition = downPosition
                previousRawDragOffset = -1
                enterSelectionMode()
                updateMouseSelection(
                    value = value,
                    currentPosition = dragBeginPosition,
                    isStartOfSelection = true,
                    adjustment = adjustment,
                )
                return true
            }

            override fun onDrag(dragPosition: Offset, adjustment: SelectionAdjustment): Boolean {
                if (!enabled || value.text.isEmpty()) return false
                // can't update selection without a layoutResult, so don't consume
                state?.layoutResult ?: return false

                updateMouseSelection(
                    value = value,
                    currentPosition = dragPosition,
                    isStartOfSelection = false,
                    adjustment = adjustment,
                )
                return true
            }

            fun updateMouseSelection(
                value: TextFieldValue,
                currentPosition: Offset,
                isStartOfSelection: Boolean,
                adjustment: SelectionAdjustment,
            ) {
                val newSelection =
                    updateSelection(
                        value = value,
                        currentPosition = currentPosition,
                        isStartOfSelection = isStartOfSelection,
                        isStartHandle = false,
                        adjustment = adjustment,
                        isTouchBasedSelection = false,
                    )
                setHandleState(if (newSelection.collapsed) Cursor else Selection)
            }

            override fun onDragDone() {
                /* Nothing to do */
            }
        }

    /**
     * [TextDragObserver] for dragging the selection handles to change the selection in TextField.
     */
    internal fun handleDragObserver(isStartHandle: Boolean): TextDragObserver =
        object : TextDragObserver {
            override fun onDown(point: Offset) {
                draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd

                // The position of the character where the drag gesture should begin. This is in
                // the inner text field coordinates.
                val handleCoordinates = getAdjustedCoordinates(getHandlePosition(isStartHandle))

                // translate to decoration box coordinates
                val layoutResult = state?.layoutResult ?: return
                val translatedPosition =
                    layoutResult.translateInnerToDecorationCoordinates(handleCoordinates)

                dragBeginPosition = translatedPosition
                currentDragPosition = translatedPosition

                // Zero out the total distance that being dragged.
                dragTotalDistance = Offset.Zero
                previousRawDragOffset = -1

                state?.isInTouchMode = true
                updateFloatingToolbar(show = false)
            }

            override fun onUp() {
                draggingHandle = null
                currentDragPosition = null
                updateFloatingToolbar(show = true)
            }

            override fun onStart(startPoint: Offset) {
                // handled in onDown
            }

            override fun onDrag(delta: Offset) {
                dragTotalDistance += delta

                currentDragPosition = dragBeginPosition + dragTotalDistance
                updateSelection(
                    value = value,
                    currentPosition = currentDragPosition!!,
                    isStartOfSelection = false,
                    isStartHandle = isStartHandle,
                    adjustment = SelectionAdjustment.CharacterWithWordAccelerate,
                    isTouchBasedSelection = true, // handle drag infers touch
                )
                updateFloatingToolbar(show = false)
            }

            override fun onStop() {
                draggingHandle = null
                currentDragPosition = null
                updateFloatingToolbar(show = true)
            }

            override fun onCancel() {
                draggingHandle = null
                currentDragPosition = null
            }
        }

    /** [TextDragObserver] for dragging the cursor to change the selection in TextField. */
    internal fun cursorDragObserver(): TextDragObserver =
        object : TextDragObserver {
            override fun onDown(point: Offset) {
                // Nothing
            }

            override fun onUp() {
                draggingHandle = null
                currentDragPosition = null
            }

            override fun onStart(startPoint: Offset) {
                // The position of the character where the drag gesture should begin. This is in
                // the inner text field coordinates.
                val handleCoordinates = getAdjustedCoordinates(getHandlePosition(true))

                // translate to decoration box coordinates
                val layoutResult = state?.layoutResult ?: return
                val translatedPosition =
                    layoutResult.translateInnerToDecorationCoordinates(handleCoordinates)

                dragBeginPosition = translatedPosition
                currentDragPosition = translatedPosition
                // Zero out the total distance that being dragged.
                dragTotalDistance = Offset.Zero
                draggingHandle = Handle.Cursor
                updateFloatingToolbar(show = false)
            }

            override fun onDrag(delta: Offset) {
                dragTotalDistance += delta

                state?.layoutResult?.let { layoutResult ->
                    currentDragPosition = dragBeginPosition + dragTotalDistance
                    val offset =
                        offsetMapping.transformedToOriginal(
                            layoutResult.getOffsetForPosition(currentDragPosition!!)
                        )

                    val newSelection = TextRange(offset, offset)

                    // Nothing changed, skip onValueChange hand hapticFeedback.
                    if (newSelection == value.selection) return

                    if (state?.isInTouchMode != false) {
                        hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                    }

                    onValueChange(
                        createTextFieldValue(
                            annotatedString = value.annotatedString,
                            selection = newSelection
                        )
                    )
                }
            }

            override fun onStop() {
                draggingHandle = null
                currentDragPosition = null
            }

            override fun onCancel() {}
        }

    /**
     * The method to record the required state values on entering the selection mode.
     *
     * Is triggered on long press or accessibility action.
     *
     * @param showFloatingToolbar whether to show the floating toolbar when entering selection mode
     */
    internal fun enterSelectionMode(showFloatingToolbar: Boolean = true) {
        if (state?.hasFocus == false) {
            focusRequester?.requestFocus()
        }
        oldValue = value
        updateFloatingToolbar(showFloatingToolbar)
        setHandleState(Selection)
    }

    /**
     * The method to record the corresponding state values on exiting the selection mode.
     *
     * Is triggered on accessibility action.
     */
    internal fun exitSelectionMode() {
        updateFloatingToolbar(show = false)
        setHandleState(None)
    }

    internal fun deselect(position: Offset? = null) {
        if (!value.selection.collapsed) {
            // if selection was not collapsed, set a default cursor location, otherwise
            // don't change the location of the cursor.
            val layoutResult = state?.layoutResult
            val newCursorOffset =
                if (position != null && layoutResult != null) {
                    offsetMapping.transformedToOriginal(layoutResult.getOffsetForPosition(position))
                } else {
                    value.selection.max
                }
            val newValue = value.copy(selection = TextRange(newCursorOffset))
            onValueChange(newValue)
        }

        // If a new cursor position is given and the text is not empty, enter the Cursor state.
        val selectionMode = if (position != null && value.text.isNotEmpty()) Cursor else None
        setHandleState(selectionMode)
        updateFloatingToolbar(show = false)
    }

    internal fun setSelectionPreviewHighlight(range: TextRange) {
        state?.selectionPreviewHighlightRange = range
        state?.deletionPreviewHighlightRange = TextRange.Zero
        if (!range.collapsed) exitSelectionMode()
    }

    internal fun setDeletionPreviewHighlight(range: TextRange) {
        state?.deletionPreviewHighlightRange = range
        state?.selectionPreviewHighlightRange = TextRange.Zero
        if (!range.collapsed) exitSelectionMode()
    }

    internal fun clearPreviewHighlight() {
        state?.deletionPreviewHighlightRange = TextRange.Zero
        state?.selectionPreviewHighlightRange = TextRange.Zero
    }

    /**
     * The method for copying text.
     *
     * If there is no selection, return. Put the selected text into the [ClipboardManager], and
     * cancel the selection, if [cancelSelection] is true. The text in the text field should be
     * unchanged. If [cancelSelection] is true, the new cursor offset should be at the end of the
     * previous selected text.
     */
    internal fun copy(cancelSelection: Boolean = true) {
        if (value.selection.collapsed) return

        // TODO(b/171947959) check if original or transformed should be copied
        clipboardManager?.setText(value.getSelectedText())

        if (!cancelSelection) return

        val newCursorOffset = value.selection.max
        val newValue =
            createTextFieldValue(
                annotatedString = value.annotatedString,
                selection = TextRange(newCursorOffset, newCursorOffset)
            )
        onValueChange(newValue)
        setHandleState(None)
    }

    internal fun onCopyWithResult(cancelSelection: Boolean = true): String? {
        if (value.selection.collapsed) return null
        val selectedText = value.getSelectedText().text

        if (!cancelSelection) return selectedText

        val newCursorOffset = value.selection.max
        val newValue = createTextFieldValue(
            annotatedString = value.annotatedString,
            selection = TextRange(newCursorOffset, newCursorOffset)
        )
        onValueChange(newValue)
        setHandleState(HandleState.None)
        return selectedText
    }

    /**
     * The method for pasting text.
     *
     * Get the text from [ClipboardManager]. If it's null, return. The new text should be the text
     * before the selected text, plus the text from the [ClipboardManager], and plus the text after
     * the selected text. Then the selection should collapse, and the new cursor offset should be
     * the end of the newly added text.
     */
    internal fun paste() {
        val text = clipboardManager?.getText() ?: return

        val newText =
            value.getTextBeforeSelection(value.text.length) +
                text +
                value.getTextAfterSelection(value.text.length)
        val newCursorOffset = value.selection.min + text.length

        val newValue =
            createTextFieldValue(
                annotatedString = newText,
                selection = TextRange(newCursorOffset, newCursorOffset)
            )
        onValueChange(newValue)
        setHandleState(None)
        undoManager?.forceNextSnapshot()
    }

    internal fun paste(text: AnnotatedString) {
        val newText = value.getTextBeforeSelection(value.text.length) +
            text +
            value.getTextAfterSelection(value.text.length)
        val newCursorOffset = value.selection.min + text.length

        val newValue = createTextFieldValue(
            annotatedString = newText,
            selection = TextRange(newCursorOffset, newCursorOffset)
        )
        onValueChange(newValue)
        setHandleState(HandleState.None)
        undoManager?.forceNextSnapshot()
    }

    /**
     * The method for cutting text.
     *
     * If there is no selection, return. Put the selected text into the [ClipboardManager]. The new
     * text should be the text before the selection plus the text after the selection. And the new
     * cursor offset should be between the text before the selection, and the text after the
     * selection.
     */
    internal fun cut() {
        if (value.selection.collapsed) return

        // TODO(b/171947959) check if original or transformed should be cut
        clipboardManager?.setText(value.getSelectedText())

        val newText =
            value.getTextBeforeSelection(value.text.length) +
                value.getTextAfterSelection(value.text.length)
        val newCursorOffset = value.selection.min

        val newValue =
            createTextFieldValue(
                annotatedString = newText,
                selection = TextRange(newCursorOffset, newCursorOffset)
            )
        onValueChange(newValue)
        setHandleState(None)
        undoManager?.forceNextSnapshot()
    }

    internal fun onCutWithResult(): String? {
        if (value.selection.collapsed) return null
        val selectedText = value.getSelectedText().text

        val newText = value.getTextBeforeSelection(value.text.length) +
            value.getTextAfterSelection(value.text.length)
        val newCursorOffset = value.selection.min

        val newValue = createTextFieldValue(
            annotatedString = newText,
            selection = TextRange(newCursorOffset, newCursorOffset)
        )
        onValueChange(newValue)
        setHandleState(HandleState.None)
        undoManager?.forceNextSnapshot()

        return selectedText
    }

    /*@VisibleForTesting*/
    internal fun selectAll() {
        val newValue =
            createTextFieldValue(
                annotatedString = value.annotatedString,
                selection = TextRange(0, value.text.length)
            )
        onValueChange(newValue)
        oldValue = oldValue.copy(selection = newValue.selection)
        enterSelectionMode(showFloatingToolbar = true)
    }

    internal fun getHandlePosition(isStartHandle: Boolean): Offset {
        val textLayoutResult = state?.layoutResult?.value ?: return Offset.Unspecified

        // If layout and value are out of sync, return unspecified.
        // This will be called again once they are in sync.
        val transformedText = transformedText ?: return Offset.Unspecified
        val layoutInputText = textLayoutResult.layoutInput.text.text
        if (transformedText.text != layoutInputText) return Offset.Unspecified

        val offset = if (isStartHandle) value.selection.start else value.selection.end
        return getSelectionHandleCoordinates(
            textLayoutResult = textLayoutResult,
            offset = offsetMapping.originalToTransformed(offset),
            isStart = isStartHandle,
            areHandlesCrossed = value.selection.reversed
        )
    }

    internal fun getHandleLineHeight(isStartHandle: Boolean): Float {
        val offset = if (isStartHandle) value.selection.start else value.selection.end
        return state?.layoutResult?.value?.getLineHeight(offset) ?: return 0f
    }

    internal fun getCursorPosition(density: Density): Offset {
        val offset = offsetMapping.originalToTransformed(value.selection.start)
        val layoutResult = state?.layoutResult!!.value
        val cursorRect =
            layoutResult.getCursorRect(offset.coerceIn(0, layoutResult.layoutInput.text.length))
        val x = with(density) { cursorRect.left + DefaultCursorThickness.toPx() / 2 }
        return Offset(x, cursorRect.bottom)
    }

    /**
     * Update the [LegacyTextFieldState.showFloatingToolbar] state and show/hide the toolbar.
     *
     * You may want to call [showSelectionToolbar] and [hideSelectionToolbar] directly without
     * updating the [LegacyTextFieldState.showFloatingToolbar] if you are simply hiding all touch
     * selection behaviors (toolbar, handles, cursor, magnifier), but want the toolbar to come back
     * when you un-hide all those behaviors.
     */
    private fun updateFloatingToolbar(show: Boolean) {
        state?.showFloatingToolbar = show
        if (show) showSelectionToolbar() else hideSelectionToolbar()
    }

    /**
     * This function get the selected region as a Rectangle region, and pass it to [TextToolbar] to
     * make the FloatingToolbar show up in the proper place. In addition, this function passes the
     * copy, paste and cut method as callbacks when "copy", "cut" or "paste" is clicked.
     */
    internal fun showSelectionToolbar() {
        if (!enabled || state?.isInTouchMode == false) return
        val isPassword = visualTransformation is PasswordVisualTransformation
        val copy: (() -> Unit)? =
            if (!value.selection.collapsed && !isPassword) {
                {
                    copy()
                    hideSelectionToolbar()
                }
            } else null

        val cut: (() -> Unit)? =
            if (!value.selection.collapsed && editable && !isPassword) {
                {
                    cut()
                    hideSelectionToolbar()
                }
            } else null

        val paste: (() -> Unit)? =
            if (editable && clipboardManager?.hasText() == true) {
                {
                    paste()
                    hideSelectionToolbar()
                }
            } else null

        val selectAll: (() -> Unit)? =
            if (value.selection.length != value.text.length) {
                { selectAll() }
            } else null

        textToolbar?.showMenu(
            rect = getContentRect(),
            onCopyRequested = copy,
            onPasteRequested = paste,
            onCutRequested = cut,
            onSelectAllRequested = selectAll
        )
    }

    internal fun hideSelectionToolbar() {
        if (textToolbar?.status == TextToolbarStatus.Shown) {
            textToolbar?.hide()
        }
    }

    /**
     * Implements the macOS select-word-on-right-click behavior.
     *
     * If the current selection does not already include [position], select the word at [position].
     */
    fun selectWordAtPositionIfNotAlreadySelected(position: Offset) {
        val layoutResult = state?.layoutResult ?: return
        val isClickedPositionInsideSelection =
            layoutResult.value.isPositionInsideSelection(
                position = layoutResult.translateDecorationToInnerCoordinates(position),
                selectionRange = value.selection,
            )
        if (!isClickedPositionInsideSelection) {
            updateSelection(
                value = value,
                currentPosition = position,
                isStartOfSelection = true,
                isStartHandle = false,
                adjustment = SelectionAdjustment.Word,
                isTouchBasedSelection = false,
            )
        }
    }

    /**
     * Check if the text in the text field changed. When the content in the text field is modified,
     * this method returns true.
     */
    internal fun isTextChanged(): Boolean {
        return oldValue.text != value.text
    }

    /**
     * Calculate selected region as [Rect]. The top is the top of the first selected line, and the
     * bottom is the bottom of the last selected line. The left is the leftmost handle's horizontal
     * coordinates, and the right is the rightmost handle's coordinates.
     */
    private fun getContentRect(): Rect {
        // if it's stale layout, return empty Rect
        state
            ?.takeIf { !it.isLayoutResultStale }
            ?.let {
                // value.selection is from the original representation.
                // we need to convert original offsets into transformed offsets to query
                // layoutResult because layoutResult belongs to the transformed text.
                val transformedStart = offsetMapping.originalToTransformed(value.selection.start)
                val transformedEnd = offsetMapping.originalToTransformed(value.selection.end)
                val startOffset =
                    state?.layoutCoordinates?.localToRoot(getHandlePosition(true)) ?: Offset.Zero
                val endOffset =
                    state?.layoutCoordinates?.localToRoot(getHandlePosition(false)) ?: Offset.Zero
                val startTop =
                    state
                        ?.layoutCoordinates
                        ?.localToRoot(
                            Offset(
                                0f,
                                it.layoutResult?.value?.getCursorRect(transformedStart)?.top ?: 0f
                            )
                        )
                        ?.y ?: 0f
                val endTop =
                    state
                        ?.layoutCoordinates
                        ?.localToRoot(
                            Offset(
                                x = 0f,
                                y = it.layoutResult?.value?.getCursorRect(transformedEnd)?.top ?: 0f
                            )
                        )
                        ?.y ?: 0f

                val left = min(startOffset.x, endOffset.x)
                val right = max(startOffset.x, endOffset.x)
                val top = min(startTop, endTop)
                val bottom =
                    max(startOffset.y, endOffset.y) + 25.dp.value * it.textDelegate.density.density

                return Rect(left, top, right, bottom)
            }

        return Rect.Zero
    }

    /**
     * Update the text field's selection based on new offsets.
     *
     * @param value the current [TextFieldValue]
     * @param currentPosition the current position of the cursor/drag in the decoration box
     *   coordinates
     * @param isStartOfSelection whether this is the first updateSelection of a selection gesture.
     *   If true, will ignore any previous selection context.
     * @param isStartHandle whether the start handle is being updated
     * @param adjustment The selection adjustment to use
     * @param isTouchBasedSelection Whether this is a touch based selection
     */
    private fun updateSelection(
        value: TextFieldValue,
        currentPosition: Offset,
        isStartOfSelection: Boolean,
        isStartHandle: Boolean,
        adjustment: SelectionAdjustment,
        isTouchBasedSelection: Boolean,
    ): TextRange {
        val layoutResult = state?.layoutResult ?: return TextRange.Zero
        val previousTransformedSelection =
            TextRange(
                offsetMapping.originalToTransformed(value.selection.start),
                offsetMapping.originalToTransformed(value.selection.end)
            )

        val currentOffset =
            layoutResult.getOffsetForPosition(
                position = currentPosition,
                coerceInVisibleBounds = false
            )

        val rawStartHandleOffset =
            if (isStartHandle || isStartOfSelection) currentOffset
            else previousTransformedSelection.start

        val rawEndHandleOffset =
            if (!isStartHandle || isStartOfSelection) currentOffset
            else previousTransformedSelection.end

        val previousSelectionLayout = previousSelectionLayout // for smart cast
        val rawPreviousHandleOffset =
            if (
                isStartOfSelection || previousSelectionLayout == null || previousRawDragOffset == -1
            ) {
                -1
            } else {
                previousRawDragOffset
            }

        val selectionLayout =
            getTextFieldSelectionLayout(
                layoutResult = layoutResult.value,
                rawStartHandleOffset = rawStartHandleOffset,
                rawEndHandleOffset = rawEndHandleOffset,
                rawPreviousHandleOffset = rawPreviousHandleOffset,
                previousSelectionRange = previousTransformedSelection,
                isStartOfSelection = isStartOfSelection,
                isStartHandle = isStartHandle,
            )

        if (!selectionLayout.shouldRecomputeSelection(previousSelectionLayout)) {
            return value.selection
        }

        this.previousSelectionLayout = selectionLayout
        previousRawDragOffset = currentOffset

        val newTransformedSelection = adjustment.adjust(selectionLayout)
        val newSelection =
            TextRange(
                start = offsetMapping.transformedToOriginal(newTransformedSelection.start.offset),
                end = offsetMapping.transformedToOriginal(newTransformedSelection.end.offset)
            )

        if (newSelection == value.selection) return value.selection

        val onlyChangeIsReversed =
            newSelection.reversed != value.selection.reversed &&
                with(newSelection) { TextRange(end, start) } == value.selection

        val bothSelectionsCollapsed = newSelection.collapsed && value.selection.collapsed
        if (
            isTouchBasedSelection &&
                value.text.isNotEmpty() &&
                !onlyChangeIsReversed &&
                !bothSelectionsCollapsed
        ) {
            hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
        }

        val newValue =
            createTextFieldValue(annotatedString = value.annotatedString, selection = newSelection)
        onValueChange(newValue)

        if (!isTouchBasedSelection) {
            updateFloatingToolbar(show = !newSelection.collapsed)
        }

        state?.isInTouchMode = isTouchBasedSelection

        // showSelectionHandleStart/End might be set to false when scrolled out of the view.
        // When the selection is updated, they must also be updated so that handles will be shown
        // or hidden correctly.
        state?.showSelectionHandleStart =
            !newSelection.collapsed && isSelectionHandleInVisibleBound(isStartHandle = true)
        state?.showSelectionHandleEnd =
            !newSelection.collapsed && isSelectionHandleInVisibleBound(isStartHandle = false)
        state?.showCursorHandle =
            newSelection.collapsed && isSelectionHandleInVisibleBound(isStartHandle = true)

        return newSelection
    }

    private fun setHandleState(handleState: HandleState) {
        state?.takeUnless { it.handleState == handleState }?.let { it.handleState = handleState }
    }

    private fun createTextFieldValue(
        annotatedString: AnnotatedString,
        selection: TextRange
    ): TextFieldValue {
        return TextFieldValue(annotatedString = annotatedString, selection = selection)
    }
}

@Composable
internal fun TextFieldSelectionHandle(
    isStartHandle: Boolean,
    direction: ResolvedTextDirection,
    manager: TextFieldSelectionManager
) {
    val observer = remember(isStartHandle, manager) { manager.handleDragObserver(isStartHandle) }

    SelectionHandle(
        offsetProvider = { manager.getHandlePosition(isStartHandle) },
        isStartHandle = isStartHandle,
        direction = direction,
        handlesCrossed = manager.value.selection.reversed,
        lineHeight = manager.getHandleLineHeight(isStartHandle),
        modifier =
            Modifier.pointerInput(observer) { detectDownAndDragGesturesWithObserver(observer) },
    )
}

/** Whether the selection handle is in the visible bound of the TextField. */
internal expect fun TextFieldSelectionManager.isSelectionHandleInVisibleBound(
    isStartHandle: Boolean
): Boolean


internal fun TextFieldSelectionManager.isSelectionHandleInVisibleBoundDefault(
    isStartHandle: Boolean
): Boolean =
    state?.layoutCoordinates?.visibleBounds()?.containsInclusive(getHandlePosition(isStartHandle))
        ?: false

/**
 * Optionally shows a magnifier widget, if the current platform supports it, for the current state
 * of a [TextFieldSelectionManager]. Should check [TextFieldSelectionManager.draggingHandle] to see
 * which handle is being dragged and then calculate the magnifier position for that handle.
 *
 * Actual implementations should as much as possible actually live in this common source set, _not_
 * the platform-specific source sets. The actual implementations of this function should then just
 * delegate to those functions.
 */
internal expect fun Modifier.textFieldMagnifier(manager: TextFieldSelectionManager): Modifier

/** @return the location of the magnifier relative to the inner text field coordinates */
internal fun calculateSelectionMagnifierCenterAndroid(
    manager: TextFieldSelectionManager,
    magnifierSize: IntSize
): Offset {
    // state read of currentDragPosition so that we always recompose on drag position changes
    val localDragPosition = manager.currentDragPosition ?: return Offset.Unspecified

    // Never show the magnifier in an empty text field.
    if (manager.transformedText?.isEmpty() != false) return Offset.Unspecified
    val rawTextOffset =
        when (manager.draggingHandle) {
            null -> return Offset.Unspecified
            Handle.Cursor,
            Handle.SelectionStart -> manager.value.selection.start
            Handle.SelectionEnd -> manager.value.selection.end
        }
    // If the text hasn't been laid out yet, don't show the magnifier.
    val textLayoutResultProxy = manager.state?.layoutResult ?: return Offset.Unspecified
    val transformedText = manager.state?.textDelegate?.text ?: return Offset.Unspecified

    val textOffset =
        manager.offsetMapping
            .originalToTransformed(rawTextOffset)
            .coerceIn(0, transformedText.length)

    val dragX = textLayoutResultProxy.translateDecorationToInnerCoordinates(localDragPosition).x

    val layoutResult = textLayoutResultProxy.value
    val line = layoutResult.getLineForOffset(textOffset)
    val lineStart = layoutResult.getLineLeft(line)
    val lineEnd = layoutResult.getLineRight(line)
    val lineMin = minOf(lineStart, lineEnd)
    val lineMax = maxOf(lineStart, lineEnd)
    val centerX = dragX.coerceIn(lineMin, lineMax)

    // Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
    // magnifier actually is). See
    // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
    // Also check whether magnifierSize is calculated. A platform magnifier instance is not
    // created until it's requested for the first time. So the size will only be calculated after we
    // return a specified offset from this function.
    // It is very unlikely that this behavior would cause a flicker since magnifier immediately
    // shows up where the pointer is being dragged. The pointer needs to drag further than the half
    // of magnifier's width to hide by the following logic.
    if (
        magnifierSize != IntSize.Zero && (dragX - centerX).absoluteValue > magnifierSize.width / 2
    ) {
        return Offset.Unspecified
    }

    // Center vertically on the current line.
    val top = layoutResult.getLineTop(line)
    val bottom = layoutResult.getLineBottom(line)
    val centerY = ((bottom - top) / 2) + top

    return Offset(centerX, centerY)
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy