MultiModalInjectionScope.kt

/*
 * Copyright 2019 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.ui.test

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt

/**
 * The receiver scope of the multi-modal input injection lambda from [performMultiModalInput].
 *
 * [MultiModalInjectionScope] brings together the receiver scopes of all individual modalities,
 * allowing you to inject gestures that consist of events from different modalities, like touch
 * and mouse. For each modality, there is a property exposing that modality: currently, we have a
 * [Touch] scope and a [Mouse] scope. See their respective docs for more information.
 *
 * All events generated for all modalities are enqueued and sent together when any scope is
 * [flushed][flush]. This can be done at any time during the lambda passed to
 * [performMultiModalInput], but the scope is always flushed after the execution of that lambda
 * so normally you don't need to flush it yourself.
 *
 * Note that because all scopes share the same event queue, so flushing one flushes them all, and
 * [disposing][dispose] one disposes them all.
 *
 * Example usage:
 * ```
 * onNodeWithTag("myWidget")
 *    .performMultiModalInput {
 *        Touch.click(center)
 *        advanceEventTime(500)
 *        @OptIn(ExperimentalTestApi::class)
 *        Mouse.dragAndDrop(topLeft, bottomRight)
 *    }
 * ```
 *
 * @see InjectionScope
 * @see TouchInjectionScope
 * @see MouseInjectionScope
 */
// TODO(fresen): add better multi modal example when we have keyboard support
sealed interface MultiModalInjectionScope : InjectionScope {
    /**
     * The receiver scope for touch input injection. See [TouchInjectionScope].
     */
    val Touch: TouchInjectionScope

    /**
     * The receiver scope for mouse input injection. See [MouseInjectionScope].
     */
    @Suppress("EXPERIMENTAL_ANNOTATION_ON_WRONG_TARGET")
    @get:ExperimentalTestApi // Required to annotate Java-facing APIs
    @ExperimentalTestApi
    val Mouse: MouseInjectionScope
}

internal class MultiModalInjectionScopeImpl(node: SemanticsNode, testContext: TestContext) :
    MultiModalInjectionScope, Density by node.layoutInfo.density {
    // TODO(b/133217292): Better error: explain which gesture couldn't be performed
    private var _semanticsNode: SemanticsNode? = node
    private val semanticsNode
        get() = checkNotNull(_semanticsNode) {
            "Can't query SemanticsNode, InjectionScope has already been disposed"
        }

    // TODO(b/133217292): Better error: explain which gesture couldn't be performed
    private var _inputDispatcher: InputDispatcher? =
        createInputDispatcher(testContext, checkNotNull(semanticsNode.root))
    internal val inputDispatcher
        get() = checkNotNull(_inputDispatcher) {
            "Can't send gesture, InjectionScope has already been disposed"
        }

    /**
     * Returns and stores the visible bounds of the [semanticsNode] we're interacting with. This
     * applies clipping, which is almost always the correct thing to do when injecting gestures,
     * as gestures operate on visible UI.
     */
    private val boundsInRoot: Rect by lazy { semanticsNode.boundsInRoot }

    /**
     * Returns the size of the visible part of the node we're interacting with. This is contrary
     * to [SemanticsNode.size], which returns the unclipped size of the node.
     */
    override val visibleSize: IntSize by lazy {
        IntSize(boundsInRoot.width.roundToInt(), boundsInRoot.height.roundToInt())
    }

    /**
     * Transforms the [position] to root coordinates.
     *
     * @param position A position in local coordinates
     * @return [position] transformed to coordinates relative to the containing root.
     */
    private fun localToRoot(position: Offset): Offset {
        return position + boundsInRoot.topLeft
    }

    private fun rootToLocal(position: Offset): Offset {
        return position - boundsInRoot.topLeft
    }

    override val viewConfiguration: ViewConfiguration
        get() = semanticsNode.layoutInfo.viewConfiguration

    override fun flush() {
        inputDispatcher.flush()
    }

    override fun dispose() {
        _semanticsNode = null
        _inputDispatcher?.also {
            _inputDispatcher = null
            try {
                it.flush()
            } finally {
                it.dispose()
            }
        }
    }

    /**
     * Adds the given [durationMillis] to the current event time, delaying the next event by that
     * time. Only valid when a gesture has already been started, or when a finished gesture is
     * resumed.
     */
    override fun advanceEventTime(durationMillis: Long) {
        inputDispatcher.advanceEventTime(durationMillis)
    }

    override val Touch: TouchInjectionScope = object : TouchInjectionScope, InjectionScope by this {
        override fun currentPosition(pointerId: Int): Offset? {
            val positionInRoot = inputDispatcher.getCurrentTouchPosition(pointerId) ?: return null
            return rootToLocal(positionInRoot)
        }

        override fun down(pointerId: Int, position: Offset) {
            val positionInRoot = localToRoot(position)
            inputDispatcher.enqueueTouchDown(pointerId, positionInRoot)
        }

        override fun updatePointerTo(pointerId: Int, position: Offset) {
            val positionInRoot = localToRoot(position)
            inputDispatcher.updateTouchPointer(pointerId, positionInRoot)
        }

        override fun move(delayMillis: Long) {
            advanceEventTime(delayMillis)
            inputDispatcher.enqueueTouchMove()
        }

        @ExperimentalTestApi
        override fun moveWithHistoryMultiPointer(
            relativeHistoricalTimes: List<Long>,
            historicalCoordinates: List<List<Offset>>,
            delayMillis: Long
        ) {
            repeat(relativeHistoricalTimes.size) {
                check(relativeHistoricalTimes[it] < 0) {
                    "Relative historical times should be negative, in order to be in the past" +
                        "(offset $it was: ${relativeHistoricalTimes[it]})"
                }
                check(relativeHistoricalTimes[it] >= -delayMillis) {
                    "Relative historical times should not be earlier than the previous event " +
                        "(offset $it was: ${relativeHistoricalTimes[it]}, ${-delayMillis})"
                }
            }

            advanceEventTime(delayMillis)
            inputDispatcher.enqueueTouchMoves(relativeHistoricalTimes, historicalCoordinates)
        }

        override fun up(pointerId: Int) {
            inputDispatcher.enqueueTouchUp(pointerId)
        }

        override fun cancel(delayMillis: Long) {
            advanceEventTime(delayMillis)
            inputDispatcher.enqueueTouchCancel()
        }
    }

    @Suppress("EXPERIMENTAL_ANNOTATION_ON_WRONG_TARGET")
    @get:ExperimentalTestApi // Required to annotate Java-facing APIs
    @ExperimentalTestApi
    override val Mouse: MouseInjectionScope = object : MouseInjectionScope, InjectionScope by this {
        override val currentPosition: Offset
            get() = rootToLocal(inputDispatcher.currentMousePosition)

        override fun moveTo(position: Offset, delayMillis: Long) {
            advanceEventTime(delayMillis)
            val positionInRoot = localToRoot(position)
            inputDispatcher.enqueueMouseMove(positionInRoot)
        }

        override fun updatePointerTo(position: Offset) {
            val positionInRoot = localToRoot(position)
            inputDispatcher.updateMousePosition(positionInRoot)
        }

        override fun press(button: MouseButton) {
            inputDispatcher.enqueueMousePress(button.buttonId)
        }

        override fun release(button: MouseButton) {
            inputDispatcher.enqueueMouseRelease(button.buttonId)
        }

        override fun enter(position: Offset, delayMillis: Long) {
            advanceEventTime(delayMillis)
            val positionInRoot = localToRoot(position)
            inputDispatcher.enqueueMouseEnter(positionInRoot)
        }

        override fun exit(position: Offset, delayMillis: Long) {
            advanceEventTime(delayMillis)
            val positionInRoot = localToRoot(position)
            inputDispatcher.enqueueMouseExit(positionInRoot)
        }

        override fun cancel(delayMillis: Long) {
            advanceEventTime(delayMillis)
            inputDispatcher.enqueueMouseCancel()
        }

        override fun scroll(delta: Float, scrollWheel: ScrollWheel) {
            inputDispatcher.enqueueMouseScroll(delta, scrollWheel)
        }
    }
}