diff --git a/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt b/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt index b235e6c4a2..61929e1776 100644 --- a/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt @@ -356,6 +356,12 @@ object SecondScreen { gravity = Gravity.CENTER setTextColor(if (action) ACCENT else TEXT) textSize = 14f + // ALWAYS two lines, not just at most two. An active tile appends a state line + // ("\n❚❚" on Pause, likewise Fast Forward), so with maxLines alone a tile grew + // the moment you used it and the row went ragged. Reserving the second line makes + // every tile the same height whether or not it is currently showing state, and it + // scales with the text size instead of being pinned to a magic dp value. + minLines = 2 maxLines = 2 if (this is Button) isAllCaps = false } @@ -369,8 +375,15 @@ object SecondScreen { SecondScreenTile.LOAD -> MainActivityRuntime.instance?.loadState() SecondScreenTile.FAST_FORWARD -> MainActivityRuntime.instance?.toggleFastForward() SecondScreenTile.PAUSE -> - if (MainActivityRuntime.eState.value == EmuState.PAUSED) MainActivityRuntime.resume() - else MainActivityRuntime.pause() + if (MainActivityRuntime.eState.value == EmuState.PAUSED) { + MainActivityRuntime.resume() + } else { + // Mark it deliberate FIRST. This is the only pause that leaves the game + // uncovered, and the stuck-paused backstop resumes exactly that state + // unless it is told the user meant it. resume() clears the flag. + MainActivityRuntime.userHeldPause.value = true + MainActivityRuntime.pause() + } SecondScreenTile.SCREENSHOT -> MainActivityRuntime.instance?.applicationContext?.let { Screenshots.capture(it) } SecondScreenTile.ASPECT -> { @@ -428,8 +441,11 @@ object SecondScreen { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, ) + // MATCH_PARENT height, not WRAP_CONTENT: inside a horizontal row this stretches every + // tile to the tallest in that row, so any residual difference (a Button's built-in + // padding against a TextView's) is absorbed rather than showing as a ragged edge. private fun rowLp() = LinearLayout.LayoutParams( - 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f, + 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f, ) private fun action(label: String, onClick: () -> Unit): View = diff --git a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt index 49cc7c0326..088ad679b6 100644 --- a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -1703,6 +1703,10 @@ private val BASE_EN: Map = mapOf( "touch.editor.scopeGlobal" to "Editing Global Default touch layout", "touch.editor.show" to "Show", "touch.editor.tapHoldOff" to "Tap-Hold Off", + "touch.editor.turboOff" to "Turbo Off", + "touch.editor.turboFast" to "Turbo Fast", + "touch.editor.turboMed" to "Turbo Med", + "touch.editor.turboSlow" to "Turbo Slow", "touch.editor.tapHoldOn" to "Tap-Hold On", "touch.pause.editLabel" to "PAUSE", "touch.profiles.infoGame" to "Choosing a profile here also sets it as this game's layout. Profiles save to the inputprofiles folder.", diff --git a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt index c526494f77..faa338c15e 100644 --- a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt +++ b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt @@ -1187,7 +1187,24 @@ open class MainActivityRuntime : ComponentActivity() { NativeApp.pause() } + /** + * A pause the USER asked for and expects to stay. + * + * The stuck-paused backstop below resumes a VM that is paused with nothing covering the + * screen, on the reasoning that such a state can only be a lost resume. That was true + * while every pause came with a frontend over it -- pauseForOverlay() is what the quick + * menu, the library and backgrounding all use. The second screen broke the assumption: + * its Pause tile is the one caller of plain pause(), and it deliberately leaves the game + * on screen. The backstop then undid it 700ms later, which is what "pause immediately + * unpauses itself" was. + * + * Compose state rather than a plain flag because the backstop keys a LaunchedEffect on it. + */ + val userHeldPause = mutableStateOf(false) + fun resume() { + // Whatever the user was holding, they are done holding it. + userHeldPause.value = false if (vmStopInProgress) return vmControl.execute { @@ -2664,7 +2681,10 @@ open class MainActivityRuntime : ComponentActivity() { val stuckPaused = !WindowImpl.frontendCovers && eState.value == EmuState.PAUSED && !WindowImpl.showLibrary.value && - !com.armsx2.ui.touch.TouchControls.editMode.value + !com.armsx2.ui.touch.TouchControls.editMode.value && + // A pause with nothing over it is not always a lost resume -- the + // second screen pauses on purpose and leaves the game visible. + !userHeldPause.value androidx.compose.runtime.LaunchedEffect(stuckPaused) { if (!stuckPaused) return@LaunchedEffect // The normal close path posts its resume after a 220 ms dismiss @@ -2673,7 +2693,8 @@ open class MainActivityRuntime : ComponentActivity() { kotlinx.coroutines.delay(700) if (eState.value != EmuState.PAUSED || WindowImpl.frontendCovers || WindowImpl.showLibrary.value || - com.armsx2.ui.touch.TouchControls.editMode.value + com.armsx2.ui.touch.TouchControls.editMode.value || + userHeldPause.value ) return@LaunchedEffect println("@@ANDROID_RESUME_RETRY@@ attempt=$it eState=${eState.value}") resume() diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/touch/TouchControls.kt b/platforms/android/app/src/main/java/com/armsx2/ui/touch/TouchControls.kt index c92c3ad203..03e9343aa2 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/touch/TouchControls.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/touch/TouchControls.kt @@ -601,6 +601,45 @@ object TouchControls { macroHandler.post(runnable) } + /** + * Rapid-fire for ONE button (#619), on the same timer and the same sampling floor as a + * macro's Frequency. + * + * Separate from [fireMacro] rather than folded into it because a macro is a SET of codes + * plus a pressure modifier, and collapsing a single button into that shape would mean + * building a list to throw it away. What matters is that both share [macroRunnables], so a + * release always finds and cancels the toggle it started, and [MACRO_MIN_STATE_MS], so the + * fastest settings still produce presses the VM actually samples. + * + * [key] namespaces concurrent users of the same button the way it does for macros. + */ + fun fireTurboButton(keycode: Int, key: String, down: Boolean, frames: Int, emit: (Int, Boolean) -> Unit) { + val runKey = "btn$keycode:$key" + if (!down) { + macroRunnables.remove(runKey)?.let { macroHandler.removeCallbacks(it) } + // Always emit the up, even mid-cycle: let go on the "on" half and the button would + // otherwise stay down in the emulator. + emit(keycode, false) + return + } + if (frames <= 0) { + emit(keycode, true) + return + } + if (macroRunnables.containsKey(runKey)) return // already firing + val periodMs = (frames * MACRO_FRAME_MS).toLong().coerceAtLeast(MACRO_MIN_STATE_MS) + var pressed = false + val runnable = object : Runnable { + override fun run() { + pressed = !pressed + emit(keycode, pressed) + macroHandler.postDelayed(this, periodMs) + } + } + macroRunnables[runKey] = runnable + macroHandler.post(runnable) + } + /** The macro a physical [keycode] triggers — only if it's bound AND has buttons * configured. Checked in the gameplay key path (Main) before normal pad routing. */ @Volatile private var runtimeMacroMap: Map? = null @@ -1307,6 +1346,12 @@ data class TouchButtonCfg( /** Tap-to-hold / latch: a tap toggles the button held (stays pressed until * tapped again) instead of momentary press. Per-button, opt-in. */ val tapToHold: Boolean = false, + /** Rapid-fire while held (#619), in frames between toggles; 0 = off, which stays the + * default. Same unit and machinery as a macro's Frequency, because it is the same idea + * applied to one button: physical buttons already had turbo and the on-screen ones did + * not, which is exactly the asymmetry the request was about. Composes with [tapToHold] — + * set both and a tap starts the autofire and the next tap stops it. */ + val turbo: Int = 0, ) { fun toJson(): JSONObject = JSONObject().apply { put("id", id.name) @@ -1315,6 +1360,7 @@ data class TouchButtonCfg( put("size", sizeDp.toDouble()) put("on", enabled) put("hold", tapToHold) + put("turbo", turbo) } companion object { @@ -1328,6 +1374,7 @@ data class TouchButtonCfg( sizeDp = json.optDouble("size", 64.0).toFloat().coerceIn(28f, 220f), enabled = json.optBoolean("on", true), tapToHold = json.optBoolean("hold", false), + turbo = json.optInt("turbo", 0).coerceIn(0, TouchControls.MACRO_FREQ_MAX), ) } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/touch/TouchControlsOverlay.kt b/platforms/android/app/src/main/java/com/armsx2/ui/touch/TouchControlsOverlay.kt index f6c440f249..0046f082d4 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/touch/TouchControlsOverlay.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/touch/TouchControlsOverlay.kt @@ -466,7 +466,7 @@ private fun ButtonWidget( .fillMaxSize() .let { if (edit) it.editGestures(cfg) - else if (inputEnabled) it.pressGestures(cfg.id.keycode, cfg.tapToHold) { p -> localPressed = p } + else if (inputEnabled) it.pressGestures(cfg.id.keycode, cfg.tapToHold, cfg.turbo) { p -> localPressed = p } else it } // Pressed feedback: every button shrinks a hair AND darkens. @@ -1715,10 +1715,17 @@ private fun isMultiTouchKind(kind: TouchButtonId.Kind): Boolean = private fun Modifier.pressGestures( keycode: Int, tapToHold: Boolean = false, + turbo: Int = 0, onPressedChange: (Boolean) -> Unit, ) = - pointerInput(keycode, tapToHold) { + pointerInput(keycode, tapToHold, turbo) { var latched = false + // One place decides hold-vs-rapid-fire, so latch and momentary get turbo for free and + // the two compose: with both on, a tap starts the autofire and the next tap stops it. + fun emitPress(on: Boolean) { + if (turbo > 0) TouchControls.fireTurboButton(keycode, "touch", on, turbo, ::sendDigital) + else sendDigital(keycode, on) + } try { awaitPointerEventScope { while (true) { @@ -1732,7 +1739,7 @@ private fun Modifier.pressGestures( // Toggle the latch on this tap-down. latched = !latched onPressedChange(latched) - sendDigital(keycode, latched) + emitPress(latched) // Consume this finger's lifetime so the same press can't // re-toggle; ignore other pointers. while (true) { @@ -1742,19 +1749,22 @@ private fun Modifier.pressGestures( } } else { onPressedChange(true) - sendDigital(keycode, true) + emitPress(true) while (true) { val next = awaitPointerEvent() val nc = next.changes.firstOrNull { it.id == id } if (nc == null || !nc.pressed) break } onPressedChange(false) - sendDigital(keycode, false) + emitPress(false) } } } } finally { - // Disposed/reconfigured while latched → don't leave the key stuck down. + // Disposed/reconfigured mid-press → don't leave the key stuck down, and don't + // leave a turbo toggling a button whose widget no longer exists. emitPress(false) + // cancels the timer AND emits the up, so it is right for both cases. + if (turbo > 0) emitPress(false) if (latched) { sendDigital(keycode, false) onPressedChange(false) @@ -2094,6 +2104,22 @@ private fun EditToolbar(modifier: Modifier = Modifier) { if (selectedCfg.id.kind == TouchButtonId.Kind.FACE || selectedCfg.id.kind == TouchButtonId.Kind.SHOULDER ) { + // Rapid-fire (#619). Cycles rather than opening a slider: the toolbar is + // the one surface that has to stay usable one-thumbed over the game, and + // three speeds cover what mashing is actually for. Frames between toggles, + // matching a macro's Frequency; 2 is about as fast as the VM can sample. + ToolbarChip( + when (selectedCfg.turbo) { + 0 -> str("touch.editor.turboOff") + in 1..2 -> str("touch.editor.turboFast") + in 3..5 -> str("touch.editor.turboMed") + else -> str("touch.editor.turboSlow") + }, + ) { + TouchControls.updateButton(selectedCfg.id) { + it.copy(turbo = when (it.turbo) { 0 -> 2; 2 -> 4; 4 -> 8; else -> 0 }) + } + } ToolbarChip(if (selectedCfg.tapToHold) str("touch.editor.tapHoldOn") else str("touch.editor.tapHoldOff")) { TouchControls.updateButton(selectedCfg.id) { it.copy(tapToHold = !it.tapToHold) } }