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