TextFieldCoreModifier.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.text2.input.internal

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.isUnspecified
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.GlobalPositionAwareModifierNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextPainter
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlin.math.min
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/**
 * Modifier element for the core functionality of [BasicTextField2] that is passed as inner
 * TextField to the decoration box. This is only half the actual modifiers for the field, the other
 * half are only attached to the decorated text field.
 *
 * This modifier mostly handles layout and draw.
 */
@OptIn(ExperimentalFoundationApi::class)
internal data class TextFieldCoreModifier(
    private val isFocused: Boolean,
    private val textLayoutState: TextLayoutState,
    private val textFieldState: TextFieldState,
    private val cursorBrush: Brush,
    private val writeable: Boolean,
    private val scrollState: ScrollState,
    private val orientation: Orientation
) : ModifierNodeElement<TextFieldCoreModifierNode>() {

    override fun create(): TextFieldCoreModifierNode = TextFieldCoreModifierNode(
        isFocused = isFocused,
        textLayoutState = textLayoutState,
        textFieldState = textFieldState,
        cursorBrush = cursorBrush,
        writable = writeable,
        scrollState = scrollState,
        orientation = orientation
    )

    override fun update(node: TextFieldCoreModifierNode): TextFieldCoreModifierNode {
        node.updateNode(
            isFocused = isFocused,
            textLayoutState = textLayoutState,
            textFieldState = textFieldState,
            cursorBrush = cursorBrush,
            writeable = writeable,
            scrollState = scrollState,
            orientation = orientation
        )
        return node
    }

    override fun InspectorInfo.inspectableProperties() {
        // no inspector info
    }
}

/** Modifier node for [TextFieldCoreModifier]. */
@OptIn(ExperimentalFoundationApi::class)
internal class TextFieldCoreModifierNode(
    private var isFocused: Boolean,
    private var textLayoutState: TextLayoutState,
    private var textFieldState: TextFieldState,
    private var cursorBrush: Brush,
    private var writable: Boolean,
    private var scrollState: ScrollState,
    private var orientation: Orientation
) : Modifier.Node(),
    LayoutModifierNode,
    DrawModifierNode,
    GlobalPositionAwareModifierNode,
    CompositionLocalConsumerModifierNode {

    /**
     * Animatable object for cursor's alpha value. It becomes 1f for half a second and 0f for
     * another half a second when TextField is focused and editable. Initial value should be 0f
     * so that when cursor needs to be drawn for the first time, change to 1f invalidates draw.
     */
    private val cursorAlpha = Animatable(0f)

    /**
     * Whether to show cursor at all when TextField has focus. This depends on enabled, read only,
     * and brush at a given time.
     */
    private val showCursor: Boolean
        get() = writable && isFocused && cursorBrush.isSpecified

    /**
     * Observes the [textFieldState] for any changes to content or selection. If a change happens,
     * cursor blink animation gets reset.
     */
    private var changeObserverJob: Job? = null

    // TODO: kdoc
    private var previousSelection: TextRange = TextRange.Zero
    private var previousCursorRect: Rect = Rect.Zero

    /**
     * Updates all the related properties and invalidates internal state based on the changes.
     */
    fun updateNode(
        isFocused: Boolean,
        textLayoutState: TextLayoutState,
        textFieldState: TextFieldState,
        cursorBrush: Brush,
        writeable: Boolean,
        scrollState: ScrollState,
        orientation: Orientation
    ) {
        val wasFocused = this.isFocused
        val previousTextFieldState = this.textFieldState

        this.isFocused = isFocused
        this.textLayoutState = textLayoutState
        this.textFieldState = textFieldState
        this.cursorBrush = cursorBrush
        this.writable = writeable
        this.scrollState = scrollState
        this.orientation = orientation

        if (!showCursor) {
            changeObserverJob?.cancel()
            changeObserverJob = null
            coroutineScope.launch { cursorAlpha.snapTo(0f) }
        } else if (!wasFocused || previousTextFieldState != textFieldState) {
            // this node is writeable, focused and gained that focus just now.
            // start the state value observation
            changeObserverJob = coroutineScope.launch {
                // Animate the cursor even when animations are disabled by the system.
                withContext(FixedMotionDurationScale) {
                    snapshotFlow { textFieldState.value }
                        .collectLatest {
                            // ensure that the value is always 1f _this_ frame by calling snapTo
                            cursorAlpha.snapTo(1f)
                            // then start the cursor blinking on animation clock (500ms on to start)
                            cursorAlpha.animateTo(0f, cursorAnimationSpec)
                        }
                }
            }
        }
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ) = if (orientation == Orientation.Vertical) {
        measureVerticalScroll(measurable, constraints)
    } else {
        measureHorizontalScroll(measurable, constraints)
    }

    override fun ContentDrawScope.draw() {
        drawContent()
        val value = textFieldState.value
        val textLayoutResult = textLayoutState.layoutResult ?: return

        if (value.selection.collapsed) {
            drawText(textLayoutResult)
            drawCursor(value.selection, textLayoutResult)
        } else {
            drawSelection(value.selection, textLayoutResult)
            drawText(textLayoutResult)
        }
    }

    override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
        textLayoutState.proxy?.innerTextFieldCoordinates = coordinates
    }

    private fun MeasureScope.measureVerticalScroll(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val currSelection = textFieldState.value.selection
        val offsetToFollow = when {
            currSelection.start != previousSelection.start -> currSelection.start
            currSelection.end != previousSelection.end -> currSelection.end
            else -> currSelection.min
        }
        previousSelection = currSelection

        // remove any height constraints for TextField since it'll be able to scroll vertically.
        val childConstraints = constraints.copy(maxHeight = Constraints.Infinity)
        val placeable = measurable.measure(childConstraints)
        // final height is the minimum of calculated or constrained maximum height.
        val height = min(placeable.height, constraints.maxHeight)

        return layout(placeable.width, height) {
            // we may need to update the scroll state to bring the cursor back into view after
            // layout is completed
            updateScrollState(
                cursorRect = getCursorRectInScroller(
                    cursorOffset = offsetToFollow,
                    textLayoutResult = textLayoutState.layoutResult,
                    rtl = layoutDirection == LayoutDirection.Rtl,
                    textFieldWidth = placeable.width
                ),
                containerSize = height,
                textFieldSize = placeable.height
            )

            placeable.placeRelative(0, -scrollState.value)
        }
    }

    private fun MeasureScope.measureHorizontalScroll(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val value = textFieldState.value
        val offsetToFollow = when {
            value.selection.start != previousSelection.start -> value.selection.start
            value.selection.end != previousSelection.end -> value.selection.end
            else -> value.selection.min
        }
        previousSelection = value.selection

        // If the maxIntrinsicWidth of the children is already smaller than the constraint, pass
        // the original constraints so that the children has more information to determine its
        // size.
        val maxIntrinsicWidth = measurable.maxIntrinsicWidth(constraints.maxHeight)

        // remove any width constraints for TextField since it'll be able to scroll horizontally.
        val childConstraints = if (maxIntrinsicWidth < constraints.maxWidth) {
            constraints
        } else {
            constraints.copy(maxWidth = Constraints.Infinity)
        }
        val placeable = measurable.measure(childConstraints)
        val width = min(placeable.width, constraints.maxWidth)

        return layout(width, placeable.height) {
            // we may need to update the scroll state to bring the cursor back into view before
            // layout is updated.
            updateScrollState(
                cursorRect = getCursorRectInScroller(
                    cursorOffset = offsetToFollow,
                    textLayoutResult = textLayoutState.layoutResult,
                    rtl = layoutDirection == LayoutDirection.Rtl,
                    textFieldWidth = placeable.width
                ),
                containerSize = width,
                textFieldSize = placeable.width
            )

            placeable.placeRelative(-scrollState.value, 0)
        }
    }

    /**
     * Updates the scroll state to make sure cursor is visible after text content, selection, or
     * layout changes. Only scroll changes won't trigger this.
     *
     * @param cursorRect Rectangle area to bring into view
     * @param containerSize Either height or width of scrollable host, depending on scroll
     * orientation
     * @param textFieldSize Either height or width of scrollable text field content, depending on
     * scroll orientation
     */
    private fun updateScrollState(
        cursorRect: Rect,
        containerSize: Int,
        textFieldSize: Int,
    ) {
        // update the maximum scroll value
        val difference = textFieldSize - containerSize
        scrollState.maxValue = difference

        // if cursor is not showing, we don't have to update the state for cursor
        if (!showCursor) return

        // Check if cursor has actually changed its location
        if (cursorRect.left != previousCursorRect.left ||
            cursorRect.top != previousCursorRect.top) {
            val vertical = orientation == Orientation.Vertical
            val cursorStart = if (vertical) cursorRect.top else cursorRect.left
            val cursorEnd = if (vertical) cursorRect.bottom else cursorRect.right

            val startVisibleBound = scrollState.value
            val endVisibleBound = startVisibleBound + containerSize
            val offsetDifference = when {
                // make bottom/end of the cursor visible
                //
                // text box
                // +----------------------+
                // |                      |
                // |                      |
                // |          cursor      |
                // |             |        |
                // +-------------|--------+
                //               |
                //
                cursorEnd > endVisibleBound -> cursorEnd - endVisibleBound

                // in rare cases when there's not enough space to fit the whole cursor, prioritise
                // the bottom/end of the cursor
                //
                //             cursor
                // text box      |
                // +-------------|--------+
                // |             |        |
                // +-------------|--------+
                //               |
                //
                cursorStart < startVisibleBound && cursorEnd - cursorStart > containerSize ->
                    cursorEnd - endVisibleBound

                // make top/start of the cursor visible if there's enough space to fit the whole
                // cursor
                //
                //               cursor
                // text box       |
                // +--------------|-------+
                // |              |       |
                // |                      |
                // |                      |
                // |                      |
                // +----------------------+
                //
                cursorStart < startVisibleBound && cursorEnd - cursorStart <= containerSize ->
                    cursorStart - startVisibleBound

                // otherwise keep current offset
                else -> 0f
            }
            previousCursorRect = cursorRect
            coroutineScope.launch {
                // this call will respect the earlier set maxValue
                // no need to coerce again.
                scrollState.scrollBy(offsetDifference)
            }
        }
    }

    /**
     * Draws the selection highlight.
     */
    private fun DrawScope.drawSelection(
        selection: TextRange,
        textLayoutResult: TextLayoutResult
    ) {
        val start = selection.min
        val end = selection.max
        if (start != end) {
            val selectionBackgroundColor = currentValueOf(LocalTextSelectionColors)
                .backgroundColor
            val selectionPath = textLayoutResult.getPathForRange(start, end)
            drawPath(selectionPath, color = selectionBackgroundColor)
        }
    }

    /**
     * Draws the text content.
     */
    private fun DrawScope.drawText(textLayoutResult: TextLayoutResult) {
        drawIntoCanvas { canvas ->
            TextPainter.paint(canvas, textLayoutResult)
        }
    }

    /**
     * Draws the cursor indicator. Do not confuse it with cursor handle which is a popup that
     * carries the cursor movement gestures.
     */
    private fun DrawScope.drawCursor(
        selection: TextRange,
        textLayoutResult: TextLayoutResult
    ) {
        // Only draw cursor if it can be shown and its alpha is higher than 0f
        // Alpha is checked before showCursor purposefully to make sure that we read
        // cursorAlpha.value in draw phase. So, when the alpha value changes, draw phase
        // invalidates.
        if (cursorAlpha.value <= 0f || !showCursor) return

        val cursorAlphaValue = cursorAlpha.value.coerceIn(0f, 1f)
        if (cursorAlphaValue == 0f) return

        val cursorRect = textLayoutResult.getCursorRect(selection.start)
        val cursorWidth = DefaultCursorThickness.toPx()
        val cursorX = (cursorRect.left + cursorWidth / 2)
            .coerceAtMost(size.width - cursorWidth / 2)

        drawLine(
            cursorBrush,
            Offset(cursorX, cursorRect.top),
            Offset(cursorX, cursorRect.bottom),
            alpha = cursorAlphaValue,
            strokeWidth = cursorWidth
        )
    }
}

private val cursorAnimationSpec: AnimationSpec<Float> = infiniteRepeatable(
    animation = keyframes {
        durationMillis = 1000
        1f at 0
        1f at 499
        0f at 500
        0f at 999
    }
)

private val DefaultCursorThickness = 2.dp

/**
 * If brush has a specified color. It's possible that [SolidColor] contains [Color.Unspecified].
 */
private val Brush.isSpecified: Boolean
    get() = !(this is SolidColor && this.value.isUnspecified)

private object FixedMotionDurationScale : MotionDurationScale {
    override val scaleFactor: Float
        get() = 1f
}

/**
 * Finds the rectangle area that corresponds to the visible cursor.
 *
 * @param cursorOffset Index of where cursor is at
 * @param textLayoutResult Current text layout to look for cursor rect.
 * @param rtl True if layout direction is RightToLeft
 * @param textFieldWidth Total width of TextField composable
 */
private fun Density.getCursorRectInScroller(
    cursorOffset: Int,
    textLayoutResult: TextLayoutResult?,
    rtl: Boolean,
    textFieldWidth: Int
): Rect {
    val cursorRect = textLayoutResult?.getCursorRect(cursorOffset) ?: Rect.Zero
    val thickness = DefaultCursorThickness.roundToPx()

    val cursorLeft = if (rtl) {
        textFieldWidth - cursorRect.left - thickness
    } else {
        cursorRect.left
    }

    val cursorRight = if (rtl) {
        textFieldWidth - cursorRect.left
    } else {
        cursorRect.left + thickness
    }
    return cursorRect.copy(left = cursorLeft, right = cursorRight)
}