TextFieldDecoratorModifier.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.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectTapAndPress
import androidx.compose.foundation.text.KeyboardActionScope
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text2.BasicTextField2
import androidx.compose.foundation.text2.input.TextEditFilter
import androidx.compose.foundation.text2.input.TextFieldCharSequence
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.foundation.text2.input.deselect
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusEventModifierNode
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequesterModifierNode
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.requestFocus
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyInputModifierNode
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.GlobalPositionAwareModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.PointerInputModifierNode
import androidx.compose.ui.node.SemanticsModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.editableText
import androidx.compose.ui.semantics.getTextLayoutResult
import androidx.compose.ui.semantics.imeAction
import androidx.compose.ui.semantics.insertTextAtCursor
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.performImeAction
import androidx.compose.ui.semantics.setSelection
import androidx.compose.ui.semantics.setText
import androidx.compose.ui.semantics.textSelectionRange
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.IntSize

/**
 * Modifier element for most of the functionality of [BasicTextField2] that is attached to the
 * decoration box. This is only half the actual modifiers for the field, the other half are only
 * attached to the internal text field.
 *
 * This modifier handles input events (both key and pointer), semantics, and focus.
 */
@OptIn(ExperimentalFoundationApi::class)
internal data class TextFieldDecoratorModifier(
    private val textFieldState: TextFieldState,
    private val textLayoutState: TextLayoutState,
    private val textInputAdapter: AndroidTextInputAdapter?,
    private val filter: TextEditFilter?,
    private val enabled: Boolean,
    private val readOnly: Boolean,
    private val keyboardOptions: KeyboardOptions,
    private val keyboardActions: KeyboardActions,
    private val singleLine: Boolean,
) : ModifierNodeElement<TextFieldDecoratorModifierNode>() {
    override fun create(): TextFieldDecoratorModifierNode = TextFieldDecoratorModifierNode(
        textFieldState = textFieldState,
        textLayoutState = textLayoutState,
        textInputAdapter = textInputAdapter,
        filter = filter,
        enabled = enabled,
        readOnly = readOnly,
        keyboardOptions = keyboardOptions,
        keyboardActions = keyboardActions,
        singleLine = singleLine,
    )

    override fun update(node: TextFieldDecoratorModifierNode): TextFieldDecoratorModifierNode {
        node.updateNode(
            textFieldState = textFieldState,
            textLayoutState = textLayoutState,
            textInputAdapter = textInputAdapter,
            filter = filter,
            enabled = enabled,
            readOnly = readOnly,
            keyboardOptions = keyboardOptions,
            keyboardActions = keyboardActions,
            singleLine = singleLine,
        )
        return node
    }

    override fun InspectorInfo.inspectableProperties() {
        // Show nothing in the inspector.
    }
}

/** Modifier node for [TextFieldDecoratorModifier]. */
@OptIn(ExperimentalFoundationApi::class)
internal class TextFieldDecoratorModifierNode(
    var textFieldState: TextFieldState,
    var textLayoutState: TextLayoutState,
    var textInputAdapter: AndroidTextInputAdapter?,
    var filter: TextEditFilter?,
    var enabled: Boolean,
    var readOnly: Boolean,
    keyboardOptions: KeyboardOptions,
    var keyboardActions: KeyboardActions,
    var singleLine: Boolean,
) : DelegatingNode(),
    SemanticsModifierNode,
    FocusRequesterModifierNode,
    FocusEventModifierNode,
    GlobalPositionAwareModifierNode,
    PointerInputModifierNode,
    KeyInputModifierNode,
    CompositionLocalConsumerModifierNode {

    private val pointerInputNode = SuspendingPointerInputModifierNode {
        detectTapAndPress(onTap = {
            if (!isFocused) {
                requestFocus()
            } else if (enabled && !readOnly) {
                textInputSession?.showSoftwareKeyboard()
            }
        })
    }
        // TODO: remove `.node` after aosp/2462416 lands and merge everything into one delegated
        //  block
        .also { delegated { it.node } }

    var keyboardOptions: KeyboardOptions = keyboardOptions.withDefaultsFrom(filter?.keyboardOptions)
        private set

    // semantics properties that require semantics invalidation
    private var lastText: CharSequence? = null
    private var lastSelection: TextRange? = null
    private var lastEnabled: Boolean = enabled

    private var isFocused: Boolean = false
    private var semanticsConfigurationCache: SemanticsConfiguration? = null
    private var textInputSession: TextInputSession? = null

    /**
     * Manages key events. These events often are sourced by a hardware keyboard but it's also
     * possible that IME or some other platform system simulates a KeyEvent.
     */
    private val textFieldKeyEventHandler = TextFieldKeyEventHandler().also {
        it.setFilter(filter)
    }

    private val keyboardActionScope = object : KeyboardActionScope {
        private val focusManager: FocusManager
            get() = currentValueOf(LocalFocusManager)

        override fun defaultKeyboardAction(imeAction: ImeAction) {
            when (imeAction) {
                ImeAction.Next -> {
                    focusManager.moveFocus(FocusDirection.Next)
                }
                ImeAction.Previous -> {
                    focusManager.moveFocus(FocusDirection.Previous)
                }
                ImeAction.Done -> {
                    textInputSession?.hideSoftwareKeyboard()
                }
                ImeAction.Go, ImeAction.Search, ImeAction.Send,
                ImeAction.Default, ImeAction.None -> Unit
            }
        }
    }

    private val onImeActionPerformed: (ImeAction) -> Unit = { imeAction ->
        val keyboardAction = when (imeAction) {
            ImeAction.Done -> keyboardActions.onDone
            ImeAction.Go -> keyboardActions.onGo
            ImeAction.Next -> keyboardActions.onNext
            ImeAction.Previous -> keyboardActions.onPrevious
            ImeAction.Search -> keyboardActions.onSearch
            ImeAction.Send -> keyboardActions.onSend
            ImeAction.Default, ImeAction.None -> null
            else -> error("invalid ImeAction")
        }
        keyboardAction?.invoke(keyboardActionScope)
            ?: keyboardActionScope.defaultKeyboardAction(imeAction)
    }

    /**
     * Updates all the related properties and invalidates internal state based on the changes.
     */
    fun updateNode(
        textFieldState: TextFieldState,
        textLayoutState: TextLayoutState,
        textInputAdapter: AndroidTextInputAdapter?,
        filter: TextEditFilter?,
        enabled: Boolean,
        readOnly: Boolean,
        keyboardOptions: KeyboardOptions,
        keyboardActions: KeyboardActions,
        singleLine: Boolean,
    ) {
        // Find the diff: current previous and new values before updating current.
        val previousWriteable = this.enabled && !this.readOnly
        val writeable = enabled && !readOnly
        val previousTextFieldState = this.textFieldState
        val previousKeyboardOptions = this.keyboardOptions

        // Apply the diff.
        this.textFieldState = textFieldState
        this.textLayoutState = textLayoutState
        this.textInputAdapter = textInputAdapter
        this.filter = filter
        this.enabled = enabled
        this.readOnly = readOnly
        this.keyboardOptions = keyboardOptions.withDefaultsFrom(filter?.keyboardOptions)
        this.keyboardActions = keyboardActions
        this.singleLine = singleLine

        // React to diff.
        // If made writable while focused, or we got a completely new state instance,
        // start a new input session.
        if (writeable != previousWriteable ||
            textFieldState != previousTextFieldState ||
            keyboardOptions != previousKeyboardOptions
        ) {
            if (writeable && isFocused) {
                // The old session will be implicitly disposed.
                textInputSession = textInputAdapter?.startInputSession(
                    textFieldState,
                    this.keyboardOptions.toImeOptions(singleLine),
                    filter,
                    onImeActionPerformed
                )
            } else if (!writeable) {
                // We were made read-only or disabled, hide the keyboard.
                disposeInputSession()
            }
        }
        textInputSession?.setFilter(filter)
        textFieldKeyEventHandler.setFilter(filter)
    }

    /**
     * The current semantics for this node. The first time this is read after an update a new
     * configuration is created by calling [generateSemantics] and then cached.
     */
    override val semanticsConfiguration: SemanticsConfiguration
        get() {
            var localSemantics = semanticsConfigurationCache
            val value = textFieldState.text
            // Cache invalidation is done here instead of only in updateNode because the text or
            // selection might change without triggering a modifier update.
            if (localSemantics == null ||
                !value.contentEquals(lastText) ||
                lastSelection != value.selectionInChars ||
                lastEnabled != enabled
            ) {
                localSemantics = generateSemantics(value, value.selectionInChars)
            }
            return localSemantics
        }

    override fun onFocusEvent(focusState: FocusState) {
        if (isFocused == focusState.isFocused) {
            return
        }
        isFocused = focusState.isFocused

        if (focusState.isFocused) {
            textInputSession = textInputAdapter?.startInputSession(
                textFieldState,
                keyboardOptions.toImeOptions(singleLine),
                filter,
                onImeActionPerformed
            )
            // TODO(halilibo): bringIntoView
        } else {
            textFieldState.deselect()
        }
    }

    override fun onDetach() {
        if (isFocused) {
            disposeInputSession()
        }
    }

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

    override fun onPointerEvent(
        pointerEvent: PointerEvent,
        pass: PointerEventPass,
        bounds: IntSize
    ) {
        pointerInputNode.onPointerEvent(pointerEvent, pass, bounds)
    }

    override fun onCancelPointerInput() {
        pointerInputNode.onCancelPointerInput()
    }

    override fun onPreKeyEvent(event: KeyEvent): Boolean {
        // TextField does not handle pre key events.
        return false
    }

    override fun onKeyEvent(event: KeyEvent): Boolean {
        return textFieldKeyEventHandler.onKeyEvent(
            event = event,
            state = textFieldState,
            textLayoutState = textLayoutState,
            editable = enabled && !readOnly,
            singleLine = singleLine,
            onSubmit = { onImeActionPerformed(keyboardOptions.imeAction) }
        )
    }

    private fun generateSemantics(
        text: CharSequence,
        selection: TextRange
    ): SemanticsConfiguration {
        lastText = text
        lastSelection = selection
        lastEnabled = enabled
        return SemanticsConfiguration().apply {
            this.isMergingSemanticsOfDescendants = true
            getTextLayoutResult {
                textLayoutState.layoutResult?.let { result -> it.add(result) } ?: false
            }
            editableText = AnnotatedString(text.toString())
            textSelectionRange = selection
            imeAction = keyboardOptions.imeAction
            if (!enabled) disabled()

            setText { text ->
                textFieldState.editProcessor.update(
                    listOf(
                        DeleteAllCommand,
                        CommitTextCommand(text, 1)
                    ),
                    filter
                )
                true
            }
            setSelection { start, end, _ ->
                // BasicTextField2 doesn't have VisualTransformation for the time being and
                // probably won't have something that uses offsetMapping design. We can safely
                // skip relativeToOriginalText flag. Assume it's always true.

                if (!enabled) {
                    false
                } else if (start == selection.start && end == selection.end) {
                    false
                } else if (start.coerceAtMost(end) >= 0 &&
                    start.coerceAtLeast(end) <= text.length
                ) {
                    // reset is required to make sure IME gets the update.
                    textFieldState.editProcessor.reset(
                        TextFieldCharSequence(
                            text = textFieldState.text,
                            selection = TextRange(start, end)
                        )
                    )
                    true
                } else {
                    false
                }
            }
            insertTextAtCursor { text ->
                textFieldState.editProcessor.update(
                    listOf(
                        // Finish composing text first because when the field is focused the IME
                        // might set composition.
                        FinishComposingTextCommand,
                        CommitTextCommand(text, 1)
                    ),
                    filter
                )
                true
            }
            performImeAction {
                onImeActionPerformed(keyboardOptions.imeAction)
                true
            }
            onClick {
                // according to the documentation, we still need to provide proper semantics actions
                // even if the state is 'disabled'
                if (!isFocused) {
                    requestFocus()
                }
                true
            }
            semanticsConfigurationCache = this
        }
    }

    private fun disposeInputSession() {
        textInputSession?.dispose()
        textInputSession = null
    }
}

/**
 * Returns a [KeyboardOptions] that is merged with [defaults], with this object's values taking
 * precedence.
 */
// TODO KeyboardOptions can't actually be merged correctly in all cases, because its properties
//  don't all have proper "unspecified" values. I think we can fix that in a backwards-compatible
//  way, but it will require adding new API outside of the text2 package so we should hold off on
//  making them until after the study.
internal fun KeyboardOptions.withDefaultsFrom(defaults: KeyboardOptions?): KeyboardOptions {
    if (defaults == null) return this
    return KeyboardOptions(
        capitalization = if (this.capitalization != KeyboardCapitalization.None) {
            this.capitalization
        } else {
            defaults.capitalization
        },
        autoCorrect = this.autoCorrect && defaults.autoCorrect,
        keyboardType = if (this.keyboardType != KeyboardType.Text) {
            this.keyboardType
        } else {
            defaults.keyboardType
        },
        imeAction = if (this.imeAction != ImeAction.Default) {
            this.imeAction
        } else {
            defaults.imeAction
        }
    )
}