diff --git a/lib/maplibre-compose/src/androidJvmTest/kotlin/org/maplibre/compose/interaction/internal/KeyAndRotaryInputTest.kt b/lib/maplibre-compose/src/androidJvmTest/kotlin/org/maplibre/compose/interaction/internal/KeyAndRotaryInputTest.kt index 11e51da3e..01e1f7041 100644 --- a/lib/maplibre-compose/src/androidJvmTest/kotlin/org/maplibre/compose/interaction/internal/KeyAndRotaryInputTest.kt +++ b/lib/maplibre-compose/src/androidJvmTest/kotlin/org/maplibre/compose/interaction/internal/KeyAndRotaryInputTest.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.requestFocus import androidx.compose.ui.test.withKeyDown import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.math.log2 import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -34,6 +35,7 @@ import org.maplibre.compose.interaction.KeyModifier import org.maplibre.compose.interaction.KeyResponse import org.maplibre.compose.interaction.ModifierMatch import org.maplibre.compose.map.GestureTestFixture +import org.maplibre.compose.map.RecordingGestureTarget @OptIn(ExperimentalAtomicApi::class, ExperimentalTestApi::class) class KeyAndRotaryInputTest { @@ -258,8 +260,8 @@ class KeyAndRotaryInputTest { pressKey(Key.Enter) keyDown(Key.DirectionRight) } - waitUntil(timeoutMillis = TIMEOUT) { target.moveCalls.size == 1 } - runOnIdle { options = InputConfiguration.NoBindings } + waitUntil(timeoutMillis = TIMEOUT) { target.moveCalls.isNotEmpty() } + runOnUiThread { options = InputConfiguration.NoBindings } waitForIdle() map.assertIsFocused() map.assert(expectValue(SemanticsProperties.StateDescription, "not engaged")) @@ -305,29 +307,71 @@ class KeyAndRotaryInputTest { } @Test - fun camera_takeover_during_a_held_key_suppresses_repeats_until_release() { + fun camera_takeover_during_a_held_key_stops_motion_until_release() { fixture.runFocusTest { target, unconsumed -> + mainClock.autoAdvance = false val map = mapNode() map.requestFocus() map.performKeyInput { pressKey(Key.Enter) keyDown(Key.DirectionRight) } - waitForIdle() + mainClock.advanceTimeBy(FRAME_MILLIS * 4) val moves = target.moveCalls.size + assertTrue(moves > 0) lateinit var newer: CameraInputToken - runOnIdle { newer = target.onGestureStarted() } - map.performKeyInput { - advanceEventTime(600) - keyUp(Key.DirectionRight) - } - waitForIdle() + runOnUiThread { newer = target.onGestureStarted() } + mainClock.advanceTimeBy(FRAME_MILLIS * 4) + assertEquals(moves, target.moveCalls.size) + map.performKeyInput { keyUp(Key.DirectionRight) } + mainClock.advanceTimeBy(600) assertEquals(moves, target.moveCalls.size) assertFalse(Key.DirectionRight in unconsumed) - runOnIdle { target.onGestureEnded(newer) } + runOnUiThread { target.onGestureEnded(newer) } map.performKeyInput { pressKey(Key.DirectionRight) } + mainClock.advanceTimeBy(600) + assertTrue(target.moveCalls.size > moves) + } + } + + @Test + fun a_held_key_pans_every_frame_and_release_ends_the_session() { + fixture.runFocusTest { target, _ -> + mainClock.autoAdvance = false + val map = mapNode() + map.requestFocus() + map.performKeyInput { + pressKey(Key.Enter) + keyDown(Key.DirectionRight) + } + mainClock.advanceTimeByFrame() + repeat(6) { + mainClock.advanceTimeByFrame() + assertEquals(it + 1, target.moveCalls.size, "frame ${it + 1}") + } + assertTrue(target.moveCalls.all { it.x < 0f && it.y == 0f }, "${target.moveCalls}") + assertEquals(0, target.endedCount) + map.performKeyInput { keyUp(Key.DirectionRight) } + waitUntil(timeoutMillis = TIMEOUT) { target.endedCount == 1 } + val settled = target.moveCalls.toList() + mainClock.advanceTimeBy(600) + assertEquals(settled, target.moveCalls) + } + } + + @Test + fun a_tap_pans_one_step() { + fixture.runFocusTest { target, _ -> + val map = mapNode() + map.requestFocus() + map.performKeyInput { + pressKey(Key.Enter) + pressKey(Key.DirectionRight) + } waitForIdle() - assertEquals(moves + 1, target.moveCalls.size) + val panStep = InputConfiguration.Standard.bindings.keys.panStep.value + assertEquals(-panStep, target.moveCalls.sumOf { it.x.toDouble() }.toFloat(), 1e-3f) + assertEquals(1, target.endedCount) } } @@ -399,7 +443,7 @@ class KeyAndRotaryInputTest { map.performRotaryScrollInput { rotateToScrollVertically(24f) } map.performKeyInput { pressKey(Key.DirectionRight) } waitForIdle() - assertEquals(1, target.moveCalls.size) + assertTrue(target.moveCalls.isNotEmpty()) assertEquals(2, target.startedCount) assertEquals(2, target.endedCount) map.performRotaryScrollInput { rotateToScrollVertically(24f) } @@ -436,12 +480,13 @@ class KeyAndRotaryInputTest { pressKey(Key.Plus) } } - waitUntil(timeoutMillis = TIMEOUT) { target.scaleCalls.size == 2 } + val zoomStep = InputConfiguration.Standard.bindings.keys.zoomStep + waitUntil(timeoutMillis = TIMEOUT) { target.zoomed() >= 2 * zoomStep - 1e-6 } assertFalse(Key.Equals in unconsumed) assertFalse(Key.Plus in unconsumed) map.performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.Equals) } } waitForIdle() - assertEquals(2, target.scaleCalls.size) + assertEquals(2 * zoomStep, target.zoomed(), 1e-6) assertTrue(Key.Equals in unconsumed) } @@ -455,8 +500,8 @@ class KeyAndRotaryInputTest { pressKey(Key.Enter) keyDown(Key.DirectionRight) } - waitUntil(timeoutMillis = TIMEOUT) { target.moveCalls.size == 1 } - runOnIdle { + waitUntil(timeoutMillis = TIMEOUT) { target.moveCalls.isNotEmpty() } + runOnUiThread { options = InputConfiguration { bindings { keys { mappings { on(Key.DirectionRight, response = KeyResponse.ZoomIn) } } } } @@ -470,7 +515,9 @@ class KeyAndRotaryInputTest { assertFalse(Key.DirectionRight in unconsumed) assertTrue(target.scaleCalls.isEmpty()) map.performKeyInput { pressKey(Key.DirectionRight) } - waitUntil(timeoutMillis = TIMEOUT) { target.scaleCalls.size == 1 } + waitUntil(timeoutMillis = TIMEOUT) { target.scaleCalls.isNotEmpty() } } } + + private fun RecordingGestureTarget.zoomed(): Double = scaleCalls.sumOf { log2(it.scale) } } diff --git a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/interaction/internal/KeyInput.kt b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/interaction/internal/KeyInput.kt index 6f34e16c3..e90f9dcfd 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/interaction/internal/KeyInput.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/interaction/internal/KeyInput.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType @@ -23,8 +24,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.maplibre.compose.camera.internal.CameraInputTarget import org.maplibre.compose.camera.internal.CameraInputToken +import org.maplibre.compose.camera.internal.inputPanBy import org.maplibre.compose.camera.internal.inputPanByAwaitingTransition +import org.maplibre.compose.camera.internal.inputRotateAndPitchBy import org.maplibre.compose.camera.internal.inputRotateAndPitchByAwaitingTransition +import org.maplibre.compose.camera.internal.inputScaleBy import org.maplibre.compose.camera.internal.inputScaleByAwaitingTransition import org.maplibre.compose.interaction.KeyModifier import org.maplibre.compose.interaction.KeyResponse @@ -110,7 +114,15 @@ internal class InputFocus(private val onChanged: (engaged: Boolean) -> Unit) { } /** - * Held keys share camera ownership; repeating a step replaces its easing, not its input lifetime. + * A camera key moves the map by one step per [InputConfiguration.animationDuration] for as long as + * it is held, applied every frame through the gesture session. A release before a full step eases + * the remainder over the rest of that duration, so a tap still moves exactly one step. Held keys + * add their directions, so opposite keys cancel and a pan can combine with a zoom. OS key repeats + * are consumed and ignored. + * + * The animator duration scale applies to the eased remainder only. At scale zero a tap jumps one + * step and a held key still moves every frame. A zero [InputConfiguration.animationDuration] has no + * rate to hold at, so every press and repeat jumps one step. */ internal class KeyInput( private val target: CameraInputTarget, @@ -119,7 +131,9 @@ internal class KeyInput( private val scope: CoroutineScope, ) { private var session: GestureInputSession? = null - private var step: Job? = null + private var hold: Job? = null + /** The fraction of a step each held camera key has moved through the hold loop. */ + private val progress = mutableMapOf() private var settings: InputConfiguration.Settings? = null fun configure(value: InputConfiguration.Settings) { @@ -148,7 +162,9 @@ internal class KeyInput( // Releases close an existing claim even if its binding was removed while the key was held. if (type == KeyEventType.KeyUp) { if (key !in focus.claimedKeys) return false - val component = focus.claimedKeys.remove(key)?.component + val action = focus.claimedKeys.remove(key) + if (action?.isCamera == true && hold != null) completeStep(key, action) + val component = action?.component if (component != null && focus.claimedKeys.values.none { it?.component == component }) session?.token?.rearm(component) finishIfReleased() @@ -201,82 +217,178 @@ internal class KeyInput( created.also { session = it } } - try { - step?.cancel() - step = - current.scope.launch(start = CoroutineStart.UNDISPATCHED) { - try { - applyStep( - action, - settings.bindings.keys, - settings.scaledAnimationDuration(), - current.token, - ) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - cancel() - throw error + if (settings.animationDuration == Duration.ZERO) { + launchStep(current, action, settings, fraction = 1.0) + } else if (previous == null && hold == null) { + hold = launchHold(current, settings) + } + return true + } + + private fun hasHeldCameraKeys(): Boolean = focus.claimedKeys.values.any { it?.isCamera == true } + + private fun launchHold(session: GestureInputSession, settings: InputConfiguration): Job = + session.scope.launch(start = CoroutineStart.UNDISPATCHED) { + try { + val keys = settings.bindings.keys + val durationNanos = settings.animationDuration.inWholeNanoseconds.toDouble() + var previous = withFrameNanos { it } + var fraction = 0.0 + while (true) { + var motion = KeyMotion.None + for ((key, action) in focus.claimedKeys) { + if (action?.isCamera != true) continue + val moved = progress[key] + // A key pressed since the previous frame starts moving at the next one. + if (moved == null) { + progress[key] = 0.0 + } else { + progress[key] = moved + fraction + motion += action.motion + } } + motion.apply(keys, fraction, session.token) + val now = withFrameNanos { it } + fraction = (now - previous) / durationNanos + previous = now } - } catch (error: Throwable) { - cancel() - throw error + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + cancel() + throw error + } } - return true + /** Eases the rest of a step that the hold loop had not finished. */ + private fun completeStep(key: Key, action: KeyResponse) { + val current = session ?: return + val remaining = 1.0 - (progress.remove(key) ?: 0.0) + if (remaining > 0.0) launchStep(current, action, options(), fraction = remaining) } - private fun hasHeldCameraKeys(): Boolean = focus.claimedKeys.values.any { it?.isCamera == true } + private fun launchStep( + session: GestureInputSession, + action: KeyResponse, + settings: InputConfiguration, + fraction: Double, + ) { + session.scope.launch(start = CoroutineStart.UNDISPATCHED) { + try { + action.motion.applyEased( + settings.bindings.keys, + fraction, + settings.scaledAnimationDuration() * fraction, + session.token, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + cancel() + throw error + } + } + } private fun finishIfReleased() { if (hasHeldCameraKeys()) return - step = null - // Retain the response so focus loss or a binding change can still cancel its easing. + hold?.cancel() + hold = null + progress.clear() + // Retain the session so focus loss or a binding change can still cancel its easing. session?.end() } fun cancel() { val previous = session session = null - step = null + hold = null + progress.clear() focus.claimedKeys.keys.toList().forEach { key -> focus.claimedKeys[key] = null } previous?.cancel() } - private suspend fun applyStep( - action: KeyResponse, + private suspend fun KeyMotion.applyEased( keys: KeyBinding, + fraction: Double, duration: Duration, token: CameraInputToken, ) { - val pan = keys.panStep.value.toDouble() + val pan = keys.panStep.value * fraction with(target) { - when (action) { - KeyResponse.PanLeft -> inputPanByAwaitingTransition(pan, 0.0, duration, token) - KeyResponse.PanRight -> inputPanByAwaitingTransition(-pan, 0.0, duration, token) - KeyResponse.PanUp -> inputPanByAwaitingTransition(0.0, pan, duration, token) - KeyResponse.PanDown -> inputPanByAwaitingTransition(0.0, -pan, duration, token) - KeyResponse.ZoomIn -> - inputScaleByAwaitingTransition(zoomLevelsToScale(keys.zoomStep), null, duration, token) - KeyResponse.ZoomOut -> - inputScaleByAwaitingTransition(zoomLevelsToScale(-keys.zoomStep), null, duration, token) - KeyResponse.RotateLeft -> - inputRotateAndPitchByAwaitingTransition(-keys.rotateStep, 0.0, duration, token) - KeyResponse.RotateRight -> - inputRotateAndPitchByAwaitingTransition(keys.rotateStep, 0.0, duration, token) - KeyResponse.TiltUp -> - inputRotateAndPitchByAwaitingTransition(0.0, keys.pitchStep, duration, token) - KeyResponse.TiltDown -> - inputRotateAndPitchByAwaitingTransition(0.0, -keys.pitchStep, duration, token) - else -> Unit - } + if (x != 0.0 || y != 0.0) inputPanByAwaitingTransition(x * pan, y * pan, duration, token) + if (zoom != 0.0) + inputScaleByAwaitingTransition( + zoomLevelsToScale(zoom * keys.zoomStep * fraction), + null, + duration, + token, + ) + if (bearing != 0.0 || pitch != 0.0) + inputRotateAndPitchByAwaitingTransition( + bearing * keys.rotateStep * fraction, + pitch * keys.pitchStep * fraction, + duration, + token, + ) + } + } + + private fun KeyMotion.apply(keys: KeyBinding, fraction: Double, token: CameraInputToken) { + val pan = keys.panStep.value * fraction + with(target) { + inputPanBy(x * pan, y * pan, token) + if (zoom != 0.0) inputScaleBy(zoomLevelsToScale(zoom * keys.zoomStep * fraction), null, token) + inputRotateAndPitchBy( + bearing * keys.rotateStep * fraction, + pitch * keys.pitchStep * fraction, + gestureToken = token, + feedback = false, + ) } } } +/** Directions in units of the configured steps. */ +private data class KeyMotion( + val x: Double = 0.0, + val y: Double = 0.0, + val zoom: Double = 0.0, + val bearing: Double = 0.0, + val pitch: Double = 0.0, +) { + operator fun plus(other: KeyMotion) = + KeyMotion( + x + other.x, + y + other.y, + zoom + other.zoom, + bearing + other.bearing, + pitch + other.pitch, + ) + + companion object { + val None = KeyMotion() + } +} + +private val KeyResponse.motion: KeyMotion + get() = + when (this) { + KeyResponse.PanLeft -> KeyMotion(x = 1.0) + KeyResponse.PanRight -> KeyMotion(x = -1.0) + KeyResponse.PanUp -> KeyMotion(y = 1.0) + KeyResponse.PanDown -> KeyMotion(y = -1.0) + KeyResponse.ZoomIn -> KeyMotion(zoom = 1.0) + KeyResponse.ZoomOut -> KeyMotion(zoom = -1.0) + KeyResponse.RotateLeft -> KeyMotion(bearing = -1.0) + KeyResponse.RotateRight -> KeyMotion(bearing = 1.0) + KeyResponse.TiltUp -> KeyMotion(pitch = 1.0) + KeyResponse.TiltDown -> KeyMotion(pitch = -1.0) + else -> KeyMotion.None + } + private val KeyResponse.component: CameraComponent? get() = when (this) { diff --git a/lib/maplibre-compose/src/commonTest/kotlin/org/maplibre/compose/interaction/internal/KeyInputTest.kt b/lib/maplibre-compose/src/commonTest/kotlin/org/maplibre/compose/interaction/internal/KeyInputTest.kt index 0c19a30a5..99711e239 100644 --- a/lib/maplibre-compose/src/commonTest/kotlin/org/maplibre/compose/interaction/internal/KeyInputTest.kt +++ b/lib/maplibre-compose/src/commonTest/kotlin/org/maplibre/compose/interaction/internal/KeyInputTest.kt @@ -1,5 +1,6 @@ package org.maplibre.compose.interaction.internal +import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import kotlin.test.AfterTest @@ -9,7 +10,9 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.maplibre.compose.camera.internal.CameraInputTarget @@ -19,9 +22,48 @@ import org.maplibre.compose.map.GestureTestFixture @OptIn(ExperimentalCoroutinesApi::class) class MapKeyInputTest { private val map = GestureTestFixture() + private val clock = BroadcastFrameClock() + private val panStep = InputConfiguration.Standard.bindings.keys.panStep.value + private val stepMillis = InputConfiguration.Standard.animationDuration.inWholeMilliseconds @AfterTest fun closeMap() = map.close() + private fun TestScope.keyInput( + options: () -> InputConfiguration = { InputConfiguration.Standard }, + target: CameraInputTarget = map.target, + ): KeyInput { + val focus = + InputFocus {} + .also { + it.hasKeyBindings = true + it.onFocusChanged(true) + it.engage(false) + } + return KeyInput( + target, + options, + focus, + CoroutineScope(backgroundScope.coroutineContext + clock), + ) + .also { it.configure(options().settings) } + } + + private fun KeyInput.down(key: Key) = + assertTrue(onSample(key, KeyEventType.KeyDown, emptySet()), "$key press was not consumed") + + private fun KeyInput.up(key: Key) = + assertTrue(onSample(key, KeyEventType.KeyUp, emptySet()), "$key release was not consumed") + + /** Sends a frame at [millis] since the test began and runs whatever it dispatched. */ + private fun TestScope.frame(millis: Long) { + runCurrent() + clock.sendFrame(millis * 1_000_000) + runCurrent() + } + + private val pannedX: Float + get() = map.target.moveCalls.sumOf { it.x.toDouble() }.toFloat() + @Test fun overlapping_keys_share_authority_and_components_rearm_only_after_their_last_release() = runTest { @@ -34,47 +76,173 @@ class MapKeyInputTest { } } map.target.updateConfiguration(options) - val focus = - InputFocus {} - .also { - it.hasKeyBindings = true - it.onFocusChanged(true) - it.engage(false) - } - val input = - KeyInput( - map.target, - { options }, - focus, - backgroundScope, - ) - input.configure(options.settings) - fun down(key: Key) = assertTrue(input.onSample(key, KeyEventType.KeyDown, emptySet())) - fun up(key: Key) = assertTrue(input.onSample(key, KeyEventType.KeyUp, emptySet())) - down(Key.DirectionLeft) - down(Key.DirectionRight) - down(Key.Plus) - down(Key.DirectionLeft) + val input = keyInput({ options }) + input.down(Key.DirectionLeft) + input.down(Key.DirectionUp) + input.down(Key.Plus) + input.down(Key.DirectionLeft) + frame(0) + frame(16) assertEquals(1, map.target.startedCount) assertEquals(1, panStarts) assertEquals(1, zoomStarts) - up(Key.DirectionLeft) - down(Key.DirectionRight) + input.up(Key.DirectionLeft) + input.down(Key.DirectionUp) + frame(32) assertEquals(1, panStarts) - up(Key.DirectionRight) - down(Key.DirectionRight) + input.up(Key.DirectionUp) + input.down(Key.DirectionUp) + frame(48) + frame(64) assertEquals(2, panStarts) - up(Key.DirectionRight) + input.up(Key.DirectionUp) assertEquals(0, map.target.endedCount) - up(Key.Plus) + input.up(Key.Plus) runCurrent() assertEquals(1, map.target.endedCount) assertFalse(input.onSample(Key.Plus, KeyEventType.KeyUp, emptySet())) } @Test - fun release_drains_the_latest_repeat_without_ending_on_an_older_cancelled_step() = runTest { - val options = InputConfiguration.Standard + fun a_held_key_moves_every_frame_and_stops_when_released() = runTest { + val input = keyInput() + input.down(Key.DirectionRight) + frame(0) + val frames = 6 + repeat(frames) { frame(16L * (it + 1)) } + val moves = map.target.moveCalls.toList() + assertEquals(frames, moves.size) + assertTrue(moves.all { it.x < 0f && it.y == 0f }, "$moves") + assertTrue(moves.all { it.x == moves.first().x }, "uneven frames: $moves") + input.up(Key.DirectionRight) + runCurrent() + assertEquals(1, map.target.endedCount) + val settled = map.target.moveCalls.toList() + frame(200) + frame(400) + assertEquals(settled, map.target.moveCalls) + } + + @Test + fun a_press_shorter_than_a_step_completes_the_step_and_a_longer_hold_stops_on_release() = + runTest { + val input = keyInput() + input.down(Key.DirectionRight) + frame(0) + frame(16) + frame(32) + input.up(Key.DirectionRight) + runCurrent() + assertEquals(-panStep, pannedX, 1e-3f) + assertEquals(1, map.target.endedCount) + map.target.moveCalls.clear() + + input.down(Key.DirectionRight) + frame(1000) + frame(1000 + stepMillis) + frame(1000 + 2 * stepMillis) + assertEquals(-2 * panStep, pannedX, 1e-3f) + input.up(Key.DirectionRight) + runCurrent() + assertEquals(-2 * panStep, pannedX, 1e-3f) + assertEquals(2, map.target.endedCount) + } + + @Test + fun a_tap_moves_one_eased_step() = runTest { + val input = keyInput() + input.down(Key.DirectionRight) + input.up(Key.DirectionRight) + runCurrent() + assertEquals(1, map.target.moveCalls.size) + assertEquals(-panStep, pannedX, 1e-3f) + assertEquals(1, map.target.endedCount) + } + + @Test + fun repeats_are_consumed_without_changing_the_motion() = runTest { + val input = keyInput() + input.down(Key.DirectionRight) + frame(0) + frame(16) + input.down(Key.DirectionRight) + input.down(Key.DirectionRight) + frame(32) + frame(48) + val moves = map.target.moveCalls + assertEquals(3, moves.size) + assertTrue(moves.all { it.x == moves.first().x }, "$moves") + input.up(Key.DirectionRight) + runCurrent() + assertEquals(-panStep, pannedX, 1e-3f) + } + + @Test + fun held_keys_combine_and_opposite_keys_cancel() = runTest { + val input = keyInput() + input.down(Key.DirectionRight) + input.down(Key.DirectionUp) + input.down(Key.Plus) + frame(0) + frame(16) + val move = map.target.moveCalls.single() + assertTrue(move.x < 0f && move.y > 0f, "$move") + assertTrue(map.target.scaleCalls.single().scale > 1.0) + input.down(Key.DirectionLeft) + frame(32) + frame(48) + val last = map.target.moveCalls.last() + assertEquals(0f, last.x) + assertTrue(last.y > 0f) + listOf(Key.DirectionRight, Key.DirectionUp, Key.Plus, Key.DirectionLeft).forEach { + input.up(it) + } + runCurrent() + assertEquals(1, map.target.endedCount) + } + + @Test + fun camera_takeover_stops_a_held_key_until_it_is_released() = runTest { + val input = keyInput() + input.down(Key.DirectionRight) + frame(0) + frame(16) + val moved = map.target.moveCalls.size + assertTrue(moved > 0) + val newer = map.target.onGestureStarted() + runCurrent() + frame(32) + frame(48) + assertEquals(moved, map.target.moveCalls.size) + assertTrue(input.onSample(Key.DirectionRight, KeyEventType.KeyDown, emptySet())) + input.up(Key.DirectionRight) + frame(64) + assertEquals(moved, map.target.moveCalls.size) + map.target.onGestureEnded(newer) + input.down(Key.DirectionRight) + input.up(Key.DirectionRight) + runCurrent() + assertEquals(moved + 1, map.target.moveCalls.size) + } + + @Test + fun a_zero_animation_duration_jumps_a_step_per_press_and_repeat() = runTest { + val options = InputConfiguration { animationDuration = Duration.ZERO } + val input = keyInput({ options }) + input.down(Key.DirectionRight) + input.down(Key.DirectionRight) + frame(0) + frame(16) + assertEquals(2, map.target.moveCalls.size) + assertEquals(-2 * panStep, pannedX, 1e-3f) + input.up(Key.DirectionRight) + runCurrent() + assertEquals(-2 * panStep, pannedX, 1e-3f) + assertEquals(1, map.target.endedCount) + } + + @Test + fun release_drains_the_remaining_step_before_ending_the_session() = runTest { val steps = mutableListOf>() val target = object : CameraInputTarget by map.target { @@ -88,34 +256,15 @@ class MapKeyInputTest { CompletableDeferred().also { steps += it }.await() } } - val focus = - InputFocus {} - .also { - it.hasKeyBindings = true - it.onFocusChanged(true) - it.engage(false) - } - val input = - KeyInput( - target, - { options }, - focus, - backgroundScope, - ) - input.configure(options.settings) - input.onSample(Key.DirectionRight, KeyEventType.KeyDown, emptySet()) - runCurrent() - val superseded = steps.single() - input.onSample(Key.DirectionRight, KeyEventType.KeyDown, emptySet()) - input.onSample(Key.DirectionRight, KeyEventType.KeyUp, emptySet()) + val input = keyInput(target = target) + input.down(Key.DirectionRight) + frame(0) + frame(16) + input.up(Key.DirectionRight) runCurrent() assertEquals(0, map.target.endedCount) assertEquals(1, map.target.startedCount) - assertEquals(2, steps.size) - superseded.complete(Unit) - runCurrent() - assertEquals(0, map.target.endedCount) - steps.last().complete(Unit) + steps.single().complete(Unit) runCurrent() assertEquals(1, map.target.endedCount) } @@ -124,33 +273,23 @@ class MapKeyInputTest { fun structural_change_suppresses_every_held_mapping_until_release() = runTest { var options = InputConfiguration.Standard map.target.updateConfiguration(options) - val focus = - InputFocus {} - .also { - it.hasKeyBindings = true - it.onFocusChanged(true) - it.engage(false) - } - val input = - KeyInput( - map.target, - { options }, - focus, - backgroundScope, - ) - input.configure(options.settings) - input.onSample(Key.DirectionRight, KeyEventType.KeyDown, emptySet()) + val input = keyInput({ options }) + input.down(Key.DirectionRight) + frame(0) + frame(16) options = InputConfiguration { bindings { keys { panStep = androidx.compose.ui.unit.Dp(50f) } } } input.configure(options.settings) + val moved = map.target.moveCalls.size assertTrue(input.onSample(Key.DirectionRight, KeyEventType.KeyDown, emptySet())) - assertEquals(1, map.target.moveCalls.size) - assertTrue(input.onSample(Key.DirectionRight, KeyEventType.KeyUp, emptySet())) - assertTrue(input.onSample(Key.DirectionRight, KeyEventType.KeyDown, emptySet())) - assertEquals(2, map.target.moveCalls.size) - assertEquals(-50f, map.target.moveCalls.last().x) - input.onSample(Key.DirectionRight, KeyEventType.KeyUp, emptySet()) + frame(32) + assertEquals(moved, map.target.moveCalls.size) + input.up(Key.DirectionRight) + input.down(Key.DirectionRight) + input.up(Key.DirectionRight) runCurrent() + assertEquals(moved + 1, map.target.moveCalls.size) + assertEquals(-50f, map.target.moveCalls.last().x) } }