Hold-to-repeat for navigation keys across keyboard, gamepad and MIDI (v0.6.0)
Navigation and value-step actions (Nav Up/Down/Left/Right, Next/Prev Column, Increment/Decrement) now auto-repeat while their control is held — fire once on press, then accelerate from 320ms to 45ms per step — and stop the instant it's released. Implemented ONCE as a shared repeater in InputRouter (pressRepeat/releaseRepeat, keyed by the physical control id), so all three hardware inputs get identical behaviour: * Keyboard: the OS's own key auto-repeat is now ignored (held notes sustain instead of retriggering) and holds are driven by the shared repeater; key-up ends it. * Gamepad: buttons, D-pad hat AND analog sticks repeat (they all funnel through controlDown/controlUp); releasing the main control stops it, releasing a chord modifier does not. * MIDI: a momentary nav CC repeats between its press (value>=64) and release (value<64). One-shots (transport, tab switch, notes, clear) are unaffected — they fire once even when held. InputRouter takes an injectable dispatcher so the new InputRouterTest drives the accelerating schedule on a virtual clock (verifies repeat-while-held, stop-on-release, one-shot-fires-once, and that an unrelated release doesn't stop the active repeat). All unit tests pass; on-device smoke confirms single-press nav + transport still work, no crash. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.input
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies the shared hold-to-repeat used by all three hardware inputs
|
||||
* ([InputRouter.pressRepeat] / [releaseRepeat]) on a virtual clock: a repeatable
|
||||
* nav action fires once on press then accelerates while held and stops the instant
|
||||
* it's released; a one-shot action fires exactly once no matter how long it's held.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class InputRouterTest {
|
||||
|
||||
@Test
|
||||
fun repeatableNavRepeatsWhileHeldThenStopsOnRelease() = runTest {
|
||||
val router = InputRouter(repeatDispatcher = StandardTestDispatcher(testScheduler))
|
||||
val got = mutableListOf<InputAction>()
|
||||
val collector = launch { router.actions.collect { got.add(it) } }
|
||||
runCurrent() // let the collector subscribe before we emit
|
||||
|
||||
router.pressRepeat("dpadUp", InputAction.NavUp)
|
||||
runCurrent()
|
||||
assertEquals("press fires exactly once immediately", 1, got.size)
|
||||
|
||||
// Hold for ~1s: 320ms to the first repeat, then accelerating (×0.8) steps.
|
||||
advanceTimeBy(1_000)
|
||||
runCurrent()
|
||||
val whileHeld = got.size
|
||||
assertTrue("should repeat several times while held (was $whileHeld)", whileHeld >= 4)
|
||||
assertTrue("every emission is the held action", got.all { it == InputAction.NavUp })
|
||||
|
||||
// Release: no further emissions, ever.
|
||||
router.releaseRepeat("dpadUp")
|
||||
advanceTimeBy(5_000)
|
||||
runCurrent()
|
||||
assertEquals("no repeats after release", whileHeld, got.size)
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneShotActionDoesNotRepeat() = runTest {
|
||||
val router = InputRouter(repeatDispatcher = StandardTestDispatcher(testScheduler))
|
||||
val got = mutableListOf<InputAction>()
|
||||
val collector = launch { router.actions.collect { got.add(it) } }
|
||||
runCurrent()
|
||||
|
||||
router.pressRepeat("btnA", InputAction.PlayPause)
|
||||
advanceTimeBy(5_000)
|
||||
runCurrent()
|
||||
|
||||
assertEquals("a non-repeatable action fires once even when held", 1, got.size)
|
||||
assertEquals(InputAction.PlayPause, got.single())
|
||||
collector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releasingAModifierKeyDoesNotStopTheHeldNavRepeat() = runTest {
|
||||
val router = InputRouter(repeatDispatcher = StandardTestDispatcher(testScheduler))
|
||||
val got = mutableListOf<InputAction>()
|
||||
val collector = launch { router.actions.collect { got.add(it) } }
|
||||
runCurrent()
|
||||
|
||||
router.pressRepeat("dpadDown", InputAction.NavDown)
|
||||
advanceTimeBy(1_000); runCurrent()
|
||||
val beforeOtherRelease = got.size
|
||||
|
||||
// Releasing some OTHER control must not stop the active repeat.
|
||||
router.releaseRepeat("someOtherButton")
|
||||
advanceTimeBy(1_000); runCurrent()
|
||||
assertTrue("repeat continues after an unrelated release", got.size > beforeOtherRelease)
|
||||
|
||||
router.releaseRepeat("dpadDown")
|
||||
val afterRelease = got.size
|
||||
advanceTimeBy(2_000); runCurrent()
|
||||
assertEquals("stops only when the held key is released", afterRelease, got.size)
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user