Second screen: pause that stays, even tiles; on-screen turbo (#619)

Pause did not stick. The stuck-paused backstop resumes a VM that is paused
with nothing covering the screen, reasoning that such a state can only be a
lost resume. That held while every pause came with a frontend over it --
pauseForOverlay() is what the quick menu, the library and backgrounding all
use. The second screen's Pause tile is the only caller of plain pause(), and
it deliberately leaves the game on screen, so the backstop undid it 700ms
later. That is the "pause immediately unpauses itself" two people reported.
A pause the user asked for is now marked as such and the backstop leaves it
alone; resume() clears the mark.

Tiles were different heights. An active tile appends a state line ("\n❚❚" on
Pause, likewise Fast Forward), and with maxLines alone a tile grew the moment
you used it, so the row went ragged -- which is what made Fast Forward the
one people noticed. Every tile now reserves both lines whether or not it is
showing state, which also scales with the text size instead of a fixed
height, and row children stretch to the tallest so a Button's padding cannot
show as a ragged edge against a TextView's.

On-screen buttons get rapid-fire (#619). Physical buttons have had turbo and
the on-screen ones never did, which is the asymmetry the request was about --
and it is the same asymmetry tap-to-hold had in the other direction. Per
button, off by default, cycled from the editor toolbar. It runs on the macro
Frequency timer rather than a second one of its own, so it inherits the
sampling floor that stops the fastest settings from emitting presses the VM
never samples, and it composes with tap-to-hold: set both and a tap starts
the autofire and the next tap stops it.

Feature requested by shinobumaehara (#619)
This commit is contained in:
jpolo1224
2026-08-24 10:27:37 -04:00
parent dbd7be271c
commit 7bdcd1be7c
5 changed files with 125 additions and 11 deletions
@@ -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 =
@@ -1703,6 +1703,10 @@ private val BASE_EN: Map<String, String> = 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.",
@@ -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()
@@ -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<Int, TouchButtonId>? = 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),
)
}
}
@@ -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) }
}