1 /*
2  * Copyright 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.ui.test
18 
19 import androidx.compose.ui.geometry.Offset
20 import androidx.compose.ui.geometry.isSpecified
21 import androidx.compose.ui.geometry.lerp
22 import androidx.compose.ui.platform.ViewConfiguration
23 import androidx.compose.ui.util.lerp
24 import kotlin.math.max
25 import kotlin.math.roundToInt
26 
27 /**
28  * The time between the button pressed and button released event in a mouse click. Determined by
29  * empirical sampling.
30  */
31 private const val SingleClickDelayMillis = 60L
32 
33 /** The default duration of mouse gestures with configurable time (e.g. [animateMoveTo]). */
34 private const val DefaultMouseGestureDurationMillis: Long = 300L
35 
36 /**
37  * The receiver scope of the mouse input injection lambda from [performMouseInput].
38  *
39  * The functions in [MouseInjectionScope] can roughly be divided into two groups: full gestures and
40  * individual mouse events. The individual mouse events are: [press], [moveTo] and friends,
41  * [release], [cancel], [scroll] and [advanceEventTime]. Full gestures are all the other functions,
42  * like [MouseInjectionScope.click], [MouseInjectionScope.doubleClick],
43  * [MouseInjectionScope.animateMoveTo], etc. These are built on top of the individual events and
44  * serve as a good example on how you can build your own full gesture functions.
45  *
46  * A mouse move event can be sent with [moveTo] and [moveBy]. The mouse position can be updated with
47  * [updatePointerTo] and [updatePointerBy], which will not send an event and only update the
48  * position internally. This can be useful if you want to send an event that is not a move event
49  * with a location other then the current location, but without sending a preceding move event. Use
50  * [press] and [release] to send button pressed and button released events. This will also send all
51  * other necessary events that keep the stream of mouse events consistent with actual mouse input,
52  * such as a hover exit event. A [cancel] event can be sent at any time when at least one button is
53  * pressed. Use [scroll] to send a mouse scroll event.
54  *
55  * The entire event injection state is shared between all `perform.*Input` methods, meaning you can
56  * continue an unfinished mouse gesture in a subsequent invocation of [performMouseInput] or
57  * [performMultiModalInput]. Note however that while the mouse's position is retained across
58  * invocation of `perform.*Input` methods, it is always manipulated in the current node's local
59  * coordinate system. That means that two subsequent invocations of [performMouseInput] on different
60  * nodes will report a different [currentPosition], even though it is actually the same position on
61  * the screen.
62  *
63  * All events sent by these methods are batched together and sent as a whole after
64  * [performMouseInput] has executed its code block.
65  *
66  * Example of performing a mouse click:
67  *
68  * @sample androidx.compose.ui.test.samples.mouseInputClick
69  *
70  * Example of scrolling the mouse wheel while the mouse button is pressed:
71  *
72  * @sample androidx.compose.ui.test.samples.mouseInputScrollWhileDown
73  * @see InjectionScope
74  */
75 @Suppress("NotCloseable")
76 interface MouseInjectionScope : InjectionScope {
77     /**
78      * Returns the current position of the mouse. The position is returned in the local coordinate
79      * system of the node with which we're interacting. (0, 0) is the top left corner of the node.
80      * If none of the move or updatePointer methods have been used yet, the mouse's position will be
81      * (0, 0) in the Compose host's coordinate system, which will be `-[topLeft]` in the node's
82      * local coordinate system.
83      */
84     val currentPosition: Offset
85 
86     /**
87      * Sends a move event [delayMillis] after the last sent event on the associated node, with the
88      * position of the mouse updated to [position]. The [position] is in the node's local coordinate
89      * system, where (0, 0) is the top left corner of the node.
90      *
91      * If no mouse buttons are pressed, a hover event will be sent instead of a move event. If the
92      * mouse wasn't hovering yet, a hover enter event is sent as well.
93      *
94      * @param position The new position of the mouse, in the node's local coordinate system
95      * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
96      *   by default.
97      */
moveTonull98     fun moveTo(position: Offset, delayMillis: Long = eventPeriodMillis)
99 
100     /**
101      * Sends a move event [delayMillis] after the last sent event on the associated node, with the
102      * position of the mouse moved by the given [delta].
103      *
104      * If no mouse buttons are pressed, a hover event will be sent instead of a move event. If the
105      * mouse wasn't hovering yet, a hover enter event is sent as well.
106      *
107      * @param delta The position for this move event, relative to the current position of the mouse.
108      *   For example, `delta = Offset(10.px, -10.px) will add 10.px to the mouse's x-position, and
109      *   subtract 10.px from the mouse's y-position.
110      * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
111      *   by default.
112      */
113     fun moveBy(delta: Offset, delayMillis: Long = eventPeriodMillis) {
114         moveTo(currentPosition + delta, delayMillis)
115     }
116 
117     /**
118      * Updates the position of the mouse to the given [position], but does not send a move or hover
119      * event. This can be useful to adjust the mouse position before sending for example a [press]
120      * event. The [position] is in the node's local coordinate system, where (0.px, 0.px) is the top
121      * left corner of the node.
122      *
123      * @param position The new position of the mouse, in the node's local coordinate system
124      */
updatePointerTonull125     fun updatePointerTo(position: Offset)
126 
127     /**
128      * Updates the position of the mouse by the given [delta], but does not send a move or hover
129      * event. This can be useful to adjust the mouse position before sending for example a [press]
130      * event.
131      *
132      * @param delta The position for this move event, relative to the current position of the mouse.
133      *   For example, `delta = Offset(10.px, -10.px) will add 10.px to the mouse's x-position, and
134      *   subtract 10.px from the mouse's y-position.
135      */
136     fun updatePointerBy(delta: Offset) {
137         updatePointerTo(currentPosition + delta)
138     }
139 
140     /**
141      * Sends a down and button pressed event for the given [button] on the associated node. When no
142      * buttons were down yet, this will exit hovering mode before the button is pressed. All events
143      * will be sent at the current event time.
144      *
145      * Throws an [IllegalStateException] if the [button] is already pressed.
146      *
147      * @param button The mouse button that is pressed. By default the primary mouse button.
148      */
pressnull149     fun press(button: MouseButton = MouseButton.Primary)
150 
151     /**
152      * Sends a button released and up event for the given [button] on the associated node. If this
153      * was the last button to be released, the mouse will enter hovering mode and send an
154      * accompanying mouse move event after the button has been released. All events will be sent at
155      * the current event time.
156      *
157      * Throws an [IllegalStateException] if the [button] is not pressed.
158      *
159      * @param button The mouse button that is released. By default the primary mouse button.
160      */
161     fun release(button: MouseButton = MouseButton.Primary)
162 
163     /**
164      * Sends a cancel event [delayMillis] after the last sent event to cancel a stream of mouse
165      * events with pressed mouse buttons. All buttons will be released as a result. A mouse cancel
166      * event can only be sent when mouse buttons are pressed.
167      *
168      * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
169      *   by default.
170      */
171     fun cancel(delayMillis: Long = eventPeriodMillis)
172 
173     /**
174      * Sends a hover enter event at the given [position], [delayMillis] after the last sent event,
175      * without sending a hover move event.
176      *
177      * An [IllegalStateException] will be thrown when mouse buttons are down, or if the mouse is
178      * already hovering.
179      *
180      * The [position] is in the node's local coordinate system, where (0, 0) is the top left corner
181      * of the node.
182      *
183      * __Note__: enter and exit events are already sent as a side effect of [movement][moveTo] when
184      * necessary. Whether or not this is part of the contract of mouse events is platform dependent,
185      * so it is highly discouraged to manually send enter or exit events. Only use this method for
186      * tests that need to make assertions about a component's state _in between_ the enter/exit and
187      * move event.
188      *
189      * @param position The new position of the mouse, in the node's local coordinate system.
190      *   [currentPosition] by default.
191      * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
192      *   by default.
193      */
194     fun enter(position: Offset = currentPosition, delayMillis: Long = eventPeriodMillis)
195 
196     /**
197      * Sends a hover exit event at the given [position], [delayMillis] after the last sent event,
198      * without sending a hover move event.
199      *
200      * An [IllegalStateException] will be thrown if the mouse was not hovering.
201      *
202      * The [position] is in the node's local coordinate system, where (0, 0) is the top left corner
203      * of the node.
204      *
205      * __Note__: enter and exit events are already sent as a side effect of [movement][moveTo] when
206      * necessary. Whether or not this is part of the contract of mouse events is platform dependent,
207      * so it is highly discouraged to manually send enter or exit events. Only use this method for
208      * tests that need to make assertions about a component's state _in between_ the enter/exit and
209      * move event.
210      *
211      * @param position The new position of the mouse, in the node's local coordinate system
212      *   [currentPosition] by default.
213      * @param delayMillis The time between the last sent event and this event. [eventPeriodMillis]
214      *   by default.
215      */
216     fun exit(position: Offset = currentPosition, delayMillis: Long = eventPeriodMillis)
217 
218     /**
219      * Sends a scroll event with the given [delta] on the given [scrollWheel]. The event will be
220      * sent at the current event time.
221      *
222      * Positive [delta] values correspond to scrolling forward (new content appears at the bottom of
223      * a column, or at the end of a row), negative values correspond to scrolling backward (new
224      * content appears at the top of a column, or at the start of a row).
225      *
226      * Note that the correlation between scroll [delta] and pixels scrolled is platform specific.
227      * For example, on Android a scroll delta of `1f` corresponds to a scroll of `64.dp`. However,
228      * on any platform, this conversion factor could change in the future to improve the mouse
229      * scroll experience.
230      *
231      * Example of how scroll could be used:
232      *
233      * @sample androidx.compose.ui.test.samples.mouseInputScrollWhileDown
234      * @param delta The amount of scroll
235      * @param scrollWheel Which scroll wheel to rotate. Can be either [ScrollWheel.Vertical] (the
236      *   default) or [ScrollWheel.Horizontal].
237      */
238     fun scroll(delta: Float, scrollWheel: ScrollWheel = ScrollWheel.Vertical)
239 }
240 
241 internal class MouseInjectionScopeImpl(private val baseScope: MultiModalInjectionScopeImpl) :
242     MouseInjectionScope, InjectionScope by baseScope {
243     private val inputDispatcher
244         get() = baseScope.inputDispatcher
245 
246     private fun localToRoot(position: Offset) = baseScope.localToRoot(position)
247 
248     override val currentPosition: Offset
249         get() = baseScope.rootToLocal(inputDispatcher.currentMousePosition)
250 
251     override fun moveTo(position: Offset, delayMillis: Long) {
252         advanceEventTime(delayMillis)
253         val positionInRoot = localToRoot(position)
254         inputDispatcher.enqueueMouseMove(positionInRoot)
255     }
256 
257     override fun updatePointerTo(position: Offset) {
258         val positionInRoot = localToRoot(position)
259         inputDispatcher.updateMousePosition(positionInRoot)
260     }
261 
262     override fun press(button: MouseButton) {
263         inputDispatcher.enqueueMousePress(button.buttonId)
264     }
265 
266     override fun release(button: MouseButton) {
267         inputDispatcher.enqueueMouseRelease(button.buttonId)
268     }
269 
270     override fun enter(position: Offset, delayMillis: Long) {
271         advanceEventTime(delayMillis)
272         val positionInRoot = localToRoot(position)
273         inputDispatcher.enqueueMouseEnter(positionInRoot)
274     }
275 
276     override fun exit(position: Offset, delayMillis: Long) {
277         advanceEventTime(delayMillis)
278         val positionInRoot = localToRoot(position)
279         inputDispatcher.enqueueMouseExit(positionInRoot)
280     }
281 
282     override fun cancel(delayMillis: Long) {
283         advanceEventTime(delayMillis)
284         inputDispatcher.enqueueMouseCancel()
285     }
286 
287     override fun scroll(delta: Float, scrollWheel: ScrollWheel) {
288         inputDispatcher.enqueueMouseScroll(delta, scrollWheel)
289     }
290 }
291 
292 /**
293  * Use [button] to click on [position], or on the current mouse position if [position] is
294  * [unspecified][Offset.Unspecified]. The [position] is in the node's local coordinate system, where
295  * (0, 0) is the top left corner of the node. The default [button] is the
296  * [primary][MouseButton.Primary] button. There is a small 60ms delay between the press and release
297  * events to have a realistic simulation.
298  *
299  * @param position The position where to click, in the node's local coordinate system. If omitted,
300  *   the [center] of the node will be used. If [unspecified][Offset.Unspecified], clicks on the
301  *   current mouse position.
302  * @param button The button to click with. Uses the [primary][MouseButton.Primary] by default.
303  */
clicknull304 fun MouseInjectionScope.click(
305     position: Offset = center,
306     button: MouseButton = MouseButton.Primary
307 ) {
308     if (position.isSpecified) {
309         updatePointerTo(position)
310     }
311     press(button)
312     advanceEventTime(SingleClickDelayMillis)
313     release(button)
314 }
315 
316 /**
317  * Secondary-click on [position], or on the current mouse position if [position] is
318  * [unspecified][Offset.Unspecified]. While the secondary mouse button is not necessarily the right
319  * mouse button (e.g. on left-handed mice), this method is still called `rightClick` for it's
320  * widespread use. The [position] is in the node's local coordinate system, where (0, 0) is the top
321  * left corner of the node.
322  *
323  * @param position The position where to click, in the node's local coordinate system. If omitted,
324  *   the [center] of the node will be used. If [unspecified][Offset.Unspecified], clicks on the
325  *   current mouse position.
326  */
rightClicknull327 fun MouseInjectionScope.rightClick(position: Offset = center) =
328     click(position, MouseButton.Secondary)
329 
330 // The average of min and max is a safe default
331 private val ViewConfiguration.defaultDoubleTapDelayMillis: Long
332     get() = (doubleTapMinTimeMillis + doubleTapTimeoutMillis) / 2
333 
334 /**
335  * Use [button] to double-click on [position], or on the current mouse position if [position] is
336  * [unspecified][Offset.Unspecified]. The [position] is in the node's local coordinate system, where
337  * (0, 0) is the top left corner of the node. The default [button] is the
338  * [primary][MouseButton.Primary] button.
339  *
340  * @param position The position where to click, in the node's local coordinate system. If omitted,
341  *   the [center] of the node will be used. If [unspecified][Offset.Unspecified], clicks on the
342  *   current mouse position.
343  * @param button The button to click with. Uses the [primary][MouseButton.Primary] by default.
344  */
345 fun MouseInjectionScope.doubleClick(
346     position: Offset = center,
347     button: MouseButton = MouseButton.Primary
348 ) {
349     click(position, button)
350     advanceEventTime(viewConfiguration.defaultDoubleTapDelayMillis)
351     click(position, button)
352 }
353 
354 /**
355  * Use [button] to triple-click on [position], or on the current mouse position if [position] is
356  * [unspecified][Offset.Unspecified]. The [position] is in the node's local coordinate system, where
357  * (0, 0) is the top left corner of the node. The default [button] is the
358  * [primary][MouseButton.Primary] button.
359  *
360  * @param position The position where to click, in the node's local coordinate system. If omitted,
361  *   the [center] of the node will be used. If [unspecified][Offset.Unspecified], clicks on the
362  *   current mouse position.
363  * @param button The button to click with. Uses the [primary][MouseButton.Primary] by default.
364  */
tripleClicknull365 fun MouseInjectionScope.tripleClick(
366     position: Offset = center,
367     button: MouseButton = MouseButton.Primary
368 ) {
369     click(position, button)
370     advanceEventTime(viewConfiguration.defaultDoubleTapDelayMillis)
371     click(position, button)
372     advanceEventTime(viewConfiguration.defaultDoubleTapDelayMillis)
373     click(position, button)
374 }
375 
376 /**
377  * Use [button] to long-click on [position], or on the current mouse position if [position] is
378  * [unspecified][Offset.Unspecified]. The [position] is in the node's local coordinate system, where
379  * (0, 0) is the top left corner of the node. The default [button] is the
380  * [primary][MouseButton.Primary] button.
381  *
382  * @param position The position where to click, in the node's local coordinate system. If omitted,
383  *   the [center] of the node will be used. If [unspecified][Offset.Unspecified], clicks on the
384  *   current mouse position.
385  * @param button The button to click with. Uses the [primary][MouseButton.Primary] by default.
386  */
longClicknull387 fun MouseInjectionScope.longClick(
388     position: Offset = center,
389     button: MouseButton = MouseButton.Primary
390 ) {
391     if (position.isSpecified) {
392         updatePointerTo(position)
393     }
394     press(button)
395     advanceEventTime(viewConfiguration.longPressTimeoutMillis + 100L)
396     release(button)
397 }
398 
399 /**
400  * Move the mouse from the [current position][MouseInjectionScope.currentPosition] to the given
401  * [position], sending a stream of move events to get an animated path of [durationMillis]
402  * milliseconds. [Move][moveTo] the mouse to the desired start position if you want to start from a
403  * different position. The [position] is in the node's local coordinate system, where (0, 0) is the
404  * top left corner of the node.
405  *
406  * Example of moving the mouse along a line:
407  *
408  * @sample androidx.compose.ui.test.samples.mouseInputAnimateMoveTo
409  * @param position The position where to move the mouse to, in the node's local coordinate system
410  * @param durationMillis The duration of the gesture. By default 300 milliseconds.
411  */
animateMoveTonull412 fun MouseInjectionScope.animateMoveTo(
413     position: Offset,
414     durationMillis: Long = DefaultMouseGestureDurationMillis
415 ) {
416     val durationFloat = durationMillis.toFloat()
417     val start = currentPosition
418     animateMoveAlong(
419         curve = { lerp(start, position, it / durationFloat) },
420         durationMillis = durationMillis
421     )
422 }
423 
424 /**
425  * Move the mouse from the [current position][MouseInjectionScope.currentPosition] by the given
426  * [delta], sending a stream of move events to get an animated path of [durationMillis]
427  * milliseconds.
428  *
429  * @param delta The position where to move the mouse to, relative to the current position of the
430  *   mouse. For example, `delta = Offset(100.px, -100.px) will move the mouse 100 pixels to the
431  *   right and 100 pixels upwards.
432  * @param durationMillis The duration of the gesture. By default 300 milliseconds.
433  */
animateMoveBynull434 fun MouseInjectionScope.animateMoveBy(
435     delta: Offset,
436     durationMillis: Long = DefaultMouseGestureDurationMillis
437 ) {
438     animateMoveTo(currentPosition + delta, durationMillis)
439 }
440 
441 /**
442  * Move the mouse along the given [curve], sending a stream of move events to get an animated path
443  * of [durationMillis] milliseconds. The mouse will initially be moved to the start of the path,
444  * `curve(0)`, if it is not already there. The positions defined by the [curve] are in the node's
445  * local coordinate system, where (0, 0) is the top left corner of the node.
446  *
447  * Example of moving the mouse along a curve:
448  *
449  * @sample androidx.compose.ui.test.samples.mouseInputAnimateMoveAlong
450  * @param curve The function that defines the position of the mouse over time for this gesture, in
451  *   the node's local coordinate system. The argument passed to the function is the time in
452  *   milliseconds since the start of the animated move, and the return value is the location of the
453  *   mouse at that point in time
454  * @param durationMillis The duration of the gesture. By default 300 milliseconds.
455  */
animateMoveAlongnull456 fun MouseInjectionScope.animateMoveAlong(
457     curve: (timeMillis: Long) -> Offset,
458     durationMillis: Long = DefaultMouseGestureDurationMillis
459 ) {
460     require(durationMillis > 0) { "Duration is 0" }
461     val start = curve(0)
462     if (start != currentPosition) {
463         // Instantly move to the start position to maintain the total durationMillis
464         moveTo(curve(0), delayMillis = 0)
465     }
466 
467     var step = 0
468     // How many steps will we take in durationMillis?
469     // At least 1, and a number that will bring as as close to eventPeriod as possible
470     val steps = max(1, (durationMillis / eventPeriodMillis.toFloat()).roundToInt())
471 
472     var tPrev = 0L
473     while (step++ < steps) {
474         val progress = step / steps.toFloat()
475         val t = lerp(0, durationMillis, progress)
476         moveTo(curve(t), delayMillis = t - tPrev)
477         tPrev = t
478     }
479 }
480 
481 /**
482  * Use [button] to drag and drop something from [start] to [end] in [durationMillis] milliseconds.
483  * The mouse position is [updated][MouseInjectionScope.updatePointerTo] to the start position before
484  * starting the gesture. The positions defined by the [start] and [end] are in the node's local
485  * coordinate system, where (0, 0) is the top left corner of the node.
486  *
487  * @param start The position where to press the primary mouse button and initiate the drag, in the
488  *   node's local coordinate system.
489  * @param end The position where to release the primary mouse button and end the drag, in the node's
490  *   local coordinate system.
491  * @param button The button to drag with. Uses the [primary][MouseButton.Primary] by default.
492  * @param durationMillis The duration of the gesture. By default 300 milliseconds.
493  */
MouseInjectionScopenull494 fun MouseInjectionScope.dragAndDrop(
495     start: Offset,
496     end: Offset,
497     button: MouseButton = MouseButton.Primary,
498     durationMillis: Long = DefaultMouseGestureDurationMillis
499 ) {
500     updatePointerTo(start)
501     press(button)
502     animateMoveTo(end, durationMillis)
503     release(button)
504 }
505 
506 /**
507  * Rotate the mouse's [scrollWheel] by the given [scrollAmount]. The total scroll delta is linearly
508  * smoothed out over a stream of scroll events between each scroll event.
509  *
510  * Positive [scrollAmount] values correspond to scrolling forward (new content appears at the bottom
511  * of a column, or at the end of a row), negative values correspond to scrolling backward (new
512  * content appears at the top of a column, or at the start of a row).
513  *
514  * Example of a horizontal smooth scroll:
515  *
516  * @sample androidx.compose.ui.test.samples.mouseInputSmoothScroll
517  * @param scrollAmount The total delta to scroll the [scrollWheel] by
518  * @param durationMillis The duration of the gesture. By default 300 milliseconds.
519  * @param scrollWheel Which scroll wheel will be rotated. By default [ScrollWheel.Vertical].
520  * @see MouseInjectionScope.scroll
521  */
smoothScrollnull522 fun MouseInjectionScope.smoothScroll(
523     scrollAmount: Float,
524     durationMillis: Long = DefaultMouseGestureDurationMillis,
525     scrollWheel: ScrollWheel = ScrollWheel.Vertical
526 ) {
527     var step = 0
528     // How many steps will we take in durationMillis?
529     // At least 1, and a number that will bring as as close to eventPeriod as possible
530     val steps = max(1, (durationMillis / eventPeriodMillis.toFloat()).roundToInt())
531 
532     var tPrev = 0L
533     var valuePrev = 0f
534     while (step++ < steps) {
535         val progress = step / steps.toFloat()
536         val t = lerp(0, durationMillis, progress)
537         val value = lerp(0f, scrollAmount, progress)
538         advanceEventTime(t - tPrev)
539         scroll(value - valuePrev, scrollWheel)
540         tPrev = t
541         valuePrev = value
542     }
543 }
544