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:
@@ -22,8 +22,8 @@ android {
|
||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 17
|
||||
versionName = "0.5.2"
|
||||
versionCode = 18
|
||||
versionName = "0.6.0"
|
||||
|
||||
// We provide our own instrumentation runner if/when tests are added.
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
@@ -132,4 +132,6 @@ dependencies {
|
||||
|
||||
// JVM unit tests for the pure model/io logic (no device needed).
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Virtual-time coroutine testing (drives the input hold-to-repeat schedule).
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
}
|
||||
|
||||
@@ -95,25 +95,28 @@ class GamepadInput(
|
||||
heldButtons.add(code)
|
||||
return true
|
||||
}
|
||||
// Chord first: any already-held control may be the modifier.
|
||||
// Chord first: any already-held control may be the modifier. Nav / value-step
|
||||
// actions auto-repeat while held (keyed on the main control code); one-shots
|
||||
// fire once. Held sticks and D-pad hats repeat too, since they arrive here.
|
||||
for (mod in heldButtons) {
|
||||
val id = bindings[GamepadChord(mod, code)]
|
||||
if (id != null) {
|
||||
heldButtons.add(code)
|
||||
InputActions.fromId(id)?.let { router.dispatch(it) }
|
||||
InputActions.fromId(id)?.let { router.pressRepeat(code, it) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
heldButtons.add(code)
|
||||
// Otherwise the plain (no-modifier) binding.
|
||||
bindings[GamepadChord(GamepadChord.NONE, code)]?.let { id ->
|
||||
InputActions.fromId(id)?.let { router.dispatch(it); return true }
|
||||
InputActions.fromId(id)?.let { router.pressRepeat(code, it); return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun controlUp(code: Int): Boolean {
|
||||
heldButtons.remove(code)
|
||||
router.releaseRepeat(code) // end this control's hold-to-repeat, if any
|
||||
// Finalize a learn capture once every control in this combo is released.
|
||||
if (learningGamepad() && learnSeq.isNotEmpty() && heldButtons.none { it in learnSeq }) {
|
||||
val main = learnSeq.last()
|
||||
|
||||
@@ -62,3 +62,19 @@ sealed interface InputAction {
|
||||
|
||||
/** Which physical device an action came from — handy for on-screen feedback. */
|
||||
enum class InputSource { TOUCH, KEYBOARD, GAMEPAD, MIDI }
|
||||
|
||||
/**
|
||||
* Actions that should AUTO-REPEAT while their physical control is held down —
|
||||
* cursor movement, column movement and value stepping. Held via
|
||||
* [InputRouter.pressRepeat], these fire once on press then accelerate. Everything
|
||||
* else (transport, tab switch, note/clear) is a one-shot and fires only on press.
|
||||
* Shared by all three hardware sources so hold-to-repeat feels identical on a
|
||||
* keyboard, a gamepad and a MIDI controller.
|
||||
*/
|
||||
val InputAction.isRepeatable: Boolean
|
||||
get() = when (this) {
|
||||
InputAction.NavUp, InputAction.NavDown, InputAction.NavLeft, InputAction.NavRight,
|
||||
InputAction.NextColumn, InputAction.PrevColumn,
|
||||
is InputAction.Increment, is InputAction.Decrement -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
@@ -3,9 +3,17 @@
|
||||
|
||||
package space.rcmd.android.sizzle.input
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A tiny message bus. Every input source calls [dispatch]; the app's ViewModel
|
||||
@@ -16,7 +24,11 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
* When "MIDI learn" mode is on, controls that aren't bound to anything are still
|
||||
* forwarded as [InputAction.RawControl] so the settings screen can capture them.
|
||||
*/
|
||||
class InputRouter {
|
||||
class InputRouter(
|
||||
/** Dispatcher the hold-to-repeat loop runs on. Defaults to a background pool;
|
||||
* tests inject a virtual-time dispatcher to drive the accelerating schedule. */
|
||||
repeatDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) {
|
||||
// extraBufferCapacity gives us headroom for fast MIDI/gamepad bursts.
|
||||
private val _actions = MutableSharedFlow<InputAction>(extraBufferCapacity = 128)
|
||||
val actions: SharedFlow<InputAction> = _actions.asSharedFlow()
|
||||
@@ -33,4 +45,52 @@ class InputRouter {
|
||||
// oldest by design (a missed nav key is harmless, a stall is not).
|
||||
_actions.tryEmit(action)
|
||||
}
|
||||
|
||||
// ---- hold-to-repeat for navigation / value-step actions ----
|
||||
// One accelerating repeater shared by every hardware source: a source calls
|
||||
// [pressRepeat] when a control goes down and [releaseRepeat] when it comes up,
|
||||
// keyed by that control's id. Only one repeat runs at a time (holding a new nav
|
||||
// control replaces the old, which matches how you'd use it), so a single job is
|
||||
// enough. Runs off the main thread; [dispatch] is thread-safe.
|
||||
private val repeatScope = CoroutineScope(SupervisorJob() + repeatDispatcher)
|
||||
@Volatile private var repeatJob: Job? = null
|
||||
@Volatile private var repeatKey: Any? = null
|
||||
|
||||
/**
|
||||
* Fire [action] once for a physical press and, if it [isRepeatable], keep
|
||||
* re-firing it while held — after [REPEAT_START_MS], accelerating by
|
||||
* [REPEAT_DECAY] each step down to [REPEAT_MIN_MS] — until [releaseRepeat] is
|
||||
* called with the same [key]. [key] identifies the physical control (a key-code,
|
||||
* a gamepad control code, a MIDI CC number) so the matching release stops it.
|
||||
*/
|
||||
fun pressRepeat(key: Any, action: InputAction) {
|
||||
dispatch(action)
|
||||
if (!action.isRepeatable) return
|
||||
repeatJob?.cancel()
|
||||
repeatKey = key
|
||||
repeatJob = repeatScope.launch {
|
||||
var interval = REPEAT_START_MS
|
||||
while (isActive) {
|
||||
delay(interval)
|
||||
dispatch(action)
|
||||
interval = (interval * REPEAT_DECAY).toLong().coerceAtLeast(REPEAT_MIN_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the auto-repeat [pressRepeat] started for [key]. No-op if [key] isn't the
|
||||
* one currently repeating (e.g. releasing a modifier, or a non-repeating key). */
|
||||
fun releaseRepeat(key: Any) {
|
||||
if (repeatKey == key) {
|
||||
repeatJob?.cancel()
|
||||
repeatJob = null
|
||||
repeatKey = null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REPEAT_START_MS = 320L // initial hold before the first repeat
|
||||
private const val REPEAT_MIN_MS = 45L // fastest repeat once accelerated
|
||||
private const val REPEAT_DECAY = 0.80 // interval shrink factor per repeat
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,18 @@ class KeyboardInput(
|
||||
|
||||
/** @return true if we consumed the event. */
|
||||
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
// Ignore the OS's own held-key auto-repeat (repeatCount > 0): note entry
|
||||
// sustains until release, and hotkey hold-to-repeat is driven by our own
|
||||
// accelerating repeater so it feels identical to the gamepad and MIDI.
|
||||
if (event.repeatCount > 0) return bindings.containsKey(keyCode) || pianoMap.containsKey(keyCode)
|
||||
|
||||
// Keyboard-hotkey learn: capture this key for the armed action, consume it.
|
||||
if (router.learnMode && router.learnSource == InputSource.KEYBOARD) {
|
||||
router.dispatch(InputAction.RawControl(InputSource.KEYBOARD, keyCode, 1f))
|
||||
return true
|
||||
}
|
||||
|
||||
bindings[keyCode]?.let { id -> return handleBoundAction(id, event) }
|
||||
bindings[keyCode]?.let { id -> return handleBoundAction(id, keyCode, event) }
|
||||
|
||||
// Piano fallback for unbound keys.
|
||||
pianoMap[keyCode]?.let { offset ->
|
||||
@@ -64,24 +69,30 @@ class KeyboardInput(
|
||||
}
|
||||
|
||||
/** Run a bound hotkey. Handles the keyboard-local ids (octave nudge, and the
|
||||
* Shift-aware column move) and delegates the rest to [InputActions]. */
|
||||
private fun handleBoundAction(id: String, event: KeyEvent): Boolean {
|
||||
* Shift-aware column move) and delegates the rest to [InputActions]. Navigation /
|
||||
* value-step actions start a hold-to-repeat keyed on [keyCode] (ended in
|
||||
* [onKeyUp]); one-shots simply fire once. */
|
||||
private fun handleBoundAction(id: String, keyCode: Int, event: KeyEvent): Boolean {
|
||||
when (id) {
|
||||
"octaveDown" -> baseOctave = (baseOctave - 1).coerceIn(0, 8)
|
||||
"octaveUp" -> baseOctave = (baseOctave + 1).coerceIn(0, 8)
|
||||
// The column key doubles as prev-column when Shift is held (classic Tab).
|
||||
"nextColumn" -> router.dispatch(
|
||||
"nextColumn" -> router.pressRepeat(
|
||||
keyCode,
|
||||
if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn,
|
||||
)
|
||||
else -> {
|
||||
val action = InputActions.fromId(id) ?: return false
|
||||
router.dispatch(action)
|
||||
router.pressRepeat(keyCode, action)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent): Boolean {
|
||||
// Stop any hold-to-repeat this key started (no-op for non-repeating keys).
|
||||
router.releaseRepeat(keyCode)
|
||||
if (bindings.containsKey(keyCode)) return true // consumed the bound hotkey
|
||||
val offset = pianoMap[keyCode] ?: return false
|
||||
router.dispatch(InputAction.NoteOff(pianoPitch(offset)))
|
||||
return true
|
||||
|
||||
@@ -97,15 +97,26 @@ class MidiInput(
|
||||
router.dispatch(InputAction.RawControl(InputSource.MIDI, cc, value / 127f))
|
||||
return
|
||||
}
|
||||
// Navigation CCs are momentary (a pad/note-button): press (value >= 64) starts
|
||||
// an accelerating hold-to-repeat, release (value < 64) ends it — same feel as a
|
||||
// held key or D-pad. Keyed on the CC number so its release stops the right one.
|
||||
val nav = when (actionId) {
|
||||
"up" -> InputAction.NavUp
|
||||
"down" -> InputAction.NavDown
|
||||
"left" -> InputAction.NavLeft
|
||||
"right" -> InputAction.NavRight
|
||||
else -> null
|
||||
}
|
||||
if (nav != null) {
|
||||
if (value >= 64) router.pressRepeat(cc, nav) else router.releaseRepeat(cc)
|
||||
return
|
||||
}
|
||||
|
||||
// Momentary controls fire on the "press" half (value >= 64).
|
||||
val action = when (actionId) {
|
||||
"playPause" -> if (value >= 64) InputAction.PlayPause else null
|
||||
"stop" -> if (value >= 64) InputAction.Stop else null
|
||||
"loop" -> if (value >= 64) InputAction.ToggleLoop else null
|
||||
"up" -> if (value >= 64) InputAction.NavUp else null
|
||||
"down" -> if (value >= 64) InputAction.NavDown else null
|
||||
"left" -> if (value >= 64) InputAction.NavLeft else null
|
||||
"right" -> if (value >= 64) InputAction.NavRight else null
|
||||
"increment" -> InputAction.Increment(1)
|
||||
"decrement" -> InputAction.Decrement(1)
|
||||
"clear" -> if (value >= 64) InputAction.ClearCell else null
|
||||
|
||||
@@ -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