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

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on. // (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 17 versionCode = 18
versionName = "0.5.2" versionName = "0.6.0"
// We provide our own instrumentation runner if/when tests are added. // We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -132,4 +132,6 @@ dependencies {
// JVM unit tests for the pure model/io logic (no device needed). // JVM unit tests for the pure model/io logic (no device needed).
testImplementation("junit:junit:4.13.2") 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")
} }

View File

@@ -95,25 +95,28 @@ class GamepadInput(
heldButtons.add(code) heldButtons.add(code)
return true 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) { for (mod in heldButtons) {
val id = bindings[GamepadChord(mod, code)] val id = bindings[GamepadChord(mod, code)]
if (id != null) { if (id != null) {
heldButtons.add(code) heldButtons.add(code)
InputActions.fromId(id)?.let { router.dispatch(it) } InputActions.fromId(id)?.let { router.pressRepeat(code, it) }
return true return true
} }
} }
heldButtons.add(code) heldButtons.add(code)
// Otherwise the plain (no-modifier) binding. // Otherwise the plain (no-modifier) binding.
bindings[GamepadChord(GamepadChord.NONE, code)]?.let { id -> 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 return false
} }
private fun controlUp(code: Int): Boolean { private fun controlUp(code: Int): Boolean {
heldButtons.remove(code) 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. // Finalize a learn capture once every control in this combo is released.
if (learningGamepad() && learnSeq.isNotEmpty() && heldButtons.none { it in learnSeq }) { if (learningGamepad() && learnSeq.isNotEmpty() && heldButtons.none { it in learnSeq }) {
val main = learnSeq.last() val main = learnSeq.last()

View File

@@ -62,3 +62,19 @@ sealed interface InputAction {
/** Which physical device an action came from — handy for on-screen feedback. */ /** Which physical device an action came from — handy for on-screen feedback. */
enum class InputSource { TOUCH, KEYBOARD, GAMEPAD, MIDI } 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
}

View File

@@ -3,9 +3,17 @@
package space.rcmd.android.sizzle.input 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.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow 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 * 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 * 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. * 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. // extraBufferCapacity gives us headroom for fast MIDI/gamepad bursts.
private val _actions = MutableSharedFlow<InputAction>(extraBufferCapacity = 128) private val _actions = MutableSharedFlow<InputAction>(extraBufferCapacity = 128)
val actions: SharedFlow<InputAction> = _actions.asSharedFlow() 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). // oldest by design (a missed nav key is harmless, a stall is not).
_actions.tryEmit(action) _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
}
} }

View File

@@ -47,13 +47,18 @@ class KeyboardInput(
/** @return true if we consumed the event. */ /** @return true if we consumed the event. */
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { 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. // Keyboard-hotkey learn: capture this key for the armed action, consume it.
if (router.learnMode && router.learnSource == InputSource.KEYBOARD) { if (router.learnMode && router.learnSource == InputSource.KEYBOARD) {
router.dispatch(InputAction.RawControl(InputSource.KEYBOARD, keyCode, 1f)) router.dispatch(InputAction.RawControl(InputSource.KEYBOARD, keyCode, 1f))
return true return true
} }
bindings[keyCode]?.let { id -> return handleBoundAction(id, event) } bindings[keyCode]?.let { id -> return handleBoundAction(id, keyCode, event) }
// Piano fallback for unbound keys. // Piano fallback for unbound keys.
pianoMap[keyCode]?.let { offset -> pianoMap[keyCode]?.let { offset ->
@@ -64,24 +69,30 @@ class KeyboardInput(
} }
/** Run a bound hotkey. Handles the keyboard-local ids (octave nudge, and the /** Run a bound hotkey. Handles the keyboard-local ids (octave nudge, and the
* Shift-aware column move) and delegates the rest to [InputActions]. */ * Shift-aware column move) and delegates the rest to [InputActions]. Navigation /
private fun handleBoundAction(id: String, event: KeyEvent): Boolean { * 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) { when (id) {
"octaveDown" -> baseOctave = (baseOctave - 1).coerceIn(0, 8) "octaveDown" -> baseOctave = (baseOctave - 1).coerceIn(0, 8)
"octaveUp" -> 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). // 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, if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn,
) )
else -> { else -> {
val action = InputActions.fromId(id) ?: return false val action = InputActions.fromId(id) ?: return false
router.dispatch(action) router.pressRepeat(keyCode, action)
} }
} }
return true return true
} }
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent): Boolean { 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 val offset = pianoMap[keyCode] ?: return false
router.dispatch(InputAction.NoteOff(pianoPitch(offset))) router.dispatch(InputAction.NoteOff(pianoPitch(offset)))
return true return true

View File

@@ -97,15 +97,26 @@ class MidiInput(
router.dispatch(InputAction.RawControl(InputSource.MIDI, cc, value / 127f)) router.dispatch(InputAction.RawControl(InputSource.MIDI, cc, value / 127f))
return 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). // Momentary controls fire on the "press" half (value >= 64).
val action = when (actionId) { val action = when (actionId) {
"playPause" -> if (value >= 64) InputAction.PlayPause else null "playPause" -> if (value >= 64) InputAction.PlayPause else null
"stop" -> if (value >= 64) InputAction.Stop else null "stop" -> if (value >= 64) InputAction.Stop else null
"loop" -> if (value >= 64) InputAction.ToggleLoop 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) "increment" -> InputAction.Increment(1)
"decrement" -> InputAction.Decrement(1) "decrement" -> InputAction.Decrement(1)
"clear" -> if (value >= 64) InputAction.ClearCell else null "clear" -> if (value >= 64) InputAction.ClearCell else null

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