diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 65a592c..85e3169 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } diff --git a/app/src/main/java/space/rcmd/android/sizzle/input/GamepadInput.kt b/app/src/main/java/space/rcmd/android/sizzle/input/GamepadInput.kt index db545c9..015620b 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/input/GamepadInput.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/input/GamepadInput.kt @@ -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() diff --git a/app/src/main/java/space/rcmd/android/sizzle/input/InputAction.kt b/app/src/main/java/space/rcmd/android/sizzle/input/InputAction.kt index 86c059b..e5b45a4 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/input/InputAction.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/input/InputAction.kt @@ -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 + } diff --git a/app/src/main/java/space/rcmd/android/sizzle/input/InputRouter.kt b/app/src/main/java/space/rcmd/android/sizzle/input/InputRouter.kt index 9ca843e..e27aec1 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/input/InputRouter.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/input/InputRouter.kt @@ -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(extraBufferCapacity = 128) val actions: SharedFlow = _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 + } } diff --git a/app/src/main/java/space/rcmd/android/sizzle/input/KeyboardInput.kt b/app/src/main/java/space/rcmd/android/sizzle/input/KeyboardInput.kt index c177419..57cf02b 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/input/KeyboardInput.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/input/KeyboardInput.kt @@ -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 diff --git a/app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt b/app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt index 60e3eda..77d8cde 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt @@ -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 diff --git a/app/src/test/java/space/rcmd/android/sizzle/input/InputRouterTest.kt b/app/src/test/java/space/rcmd/android/sizzle/input/InputRouterTest.kt new file mode 100644 index 0000000..c515c6a --- /dev/null +++ b/app/src/test/java/space/rcmd/android/sizzle/input/InputRouterTest.kt @@ -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() + 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() + 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() + 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() + } +}