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:
Reactorcoremeltdown
2026-07-18 15:14:34 +02:00
parent e0fe89d62c
commit 750d928ce1
7 changed files with 209 additions and 15 deletions

View File

@@ -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()
}
}