Android: draw the pause-menu highlight from the registry

The pause menu painted one selection and moved another. Its action rows
took their tint from selectedAction, an index in the view model that
reset on tab change and advanced only when a row was ACTIVATED, while
the D-pad moved the nav registry, which drew its own focus ring. Two
selections, two highlights, and the row you were pointing at was not the
one lit up — which is the symptom this branch was opened for.

The registry was already the real one: every grid row has registered a
controllerFocusable id all along. So the fix is to read the highlight
from it and delete the other model outright — the state field, both
places that reset it, moveSelection, selectAction, activateSelection,
and the hardcoded per-tab count table. None of them had callers left;
only the highlight had survived, which is exactly why the two could
disagree without anything failing loudly.

Deleting that count table is worth it on its own. It duplicated each
pane's row count as a literal, and its own comment records the last time
they drifted: it read 4 against a list of 5, so the pad could not reach
Close at all. Nothing derives a count now.

One intended visible change: no row is tinted while focus is on the tab
column. Previously row 0 was always tinted regardless of where the pad
actually was, which is the same lie in a quieter form.
This commit is contained in:
Brian Degenhardt
2026-08-02 20:49:35 -07:00
parent 74b361e014
commit d80571fdaf
2 changed files with 13 additions and 74 deletions
@@ -639,8 +639,6 @@ private fun SessionPane(state: EmulationMenuUiState, viewModel: EmulationMenuVie
MainActivityRuntime.closeGame()
},
),
selected = state.selectedAction,
onSelect = viewModel::selectAction,
)
// On-screen display — a single universal on/off (old-UI style); the per-stat
// toggles live in All Settings. Plus a frame-limit switch so fast-forward is one
@@ -692,8 +690,9 @@ private fun SessionPane(state: EmulationMenuUiState, viewModel: EmulationMenuVie
Spacer(Modifier.height(6.dp))
// OSD colour, cycled in place. Shares the palette with the All Settings picker rather
// than carrying its own copy. Safe to add here: this card's rows are plain switches with
// their own callbacks — SessionPane's selectedAction indexes the action GRID above, not
// these, so inserting a row can't shift the controller dispatch.
// their own callbacks, and every control on this pane — grid rows included — now
// registers its own id with the nav registry, so inserting a row cannot shift what any
// other row does.
val osdColorIndex = com.armsx2.ui.settings.OSD_COLORS
.indexOf(state.settings.osdColor).coerceAtLeast(0)
MenuCycleRow(
@@ -1340,15 +1339,20 @@ private data class MenuAction(
)
@Composable
private fun ActionGrid(actions: List<MenuAction>, selected: Int, onSelect: (Int) -> Unit) {
private fun ActionGrid(actions: List<MenuAction>) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEachIndexed { index, item ->
val active = index == selected
val id = "pause.action.$index"
// The registry is the ONE source of truth for which row is selected. The tint used
// to come from a separate index in the view model that advanced only when a row was
// ACTIVATED, while the D-pad moved the registry — so the menu drew one selection and
// moved another, and the row you were pointing at was never the one lit up.
val active = com.armsx2.ui.settings.SettingsControllerNav.isSelected(id)
Surface(
onClick = { onSelect(index); item.action() },
onClick = item.action,
modifier = Modifier
.fillMaxWidth()
.controllerFocusable("pause.action.$index", onConfirm = { onSelect(index); item.action() }),
.controllerFocusable(id, onConfirm = item.action),
shape = RoundedCornerShape(16.dp),
color = if (active) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f),
@@ -25,7 +25,6 @@ enum class EmulationMenuTab(val titleKey: String) {
data class EmulationMenuUiState(
val tab: EmulationMenuTab = EmulationMenuTab.Session,
val selectedAction: Int = 0,
val saveSlot: Int = 0,
val settings: Settings = Settings(),
val touchControlsVisible: Boolean = true,
@@ -76,7 +75,6 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
}
state.value = state.value.copy(
tab = initialTab ?: state.value.tab,
selectedAction = 0,
saveSlot = MainActivityRuntime.currentSaveSlot.value,
settings = settings,
touchControlsVisible = com.armsx2.ui.touch.TouchControls.visible.value,
@@ -97,7 +95,7 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
fun selectTab(tab: EmulationMenuTab) {
// Nav tick when flipping to a different in-game menu tab (bumpers via cycleTab, or a tap).
if (tab != state.value.tab) com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.NAV)
state.value = state.value.copy(tab = tab, selectedAction = 0)
state.value = state.value.copy(tab = tab)
}
fun cycleTab(delta: Int) {
@@ -106,57 +104,6 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
selectTab(tabs[(current + delta).floorMod(tabs.size)])
}
fun moveSelection(delta: Int) {
val max = actionCount(state.value.tab) - 1
val before = state.value.selectedAction
val next = (before + delta).coerceIn(0, max.coerceAtLeast(0))
if (next != before) com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.NAV)
state.value = state.value.copy(selectedAction = next)
}
fun selectAction(index: Int) {
state.value = state.value.copy(selectedAction = index)
}
fun activateSelection() {
when (state.value.tab) {
EmulationMenuTab.Session -> when (state.value.selectedAction) {
0 -> resume()
1 -> MainActivityRuntime.restart()
2 -> MainActivityRuntime.promptSwapDisc()
3 -> MainActivityRuntime.closeGame()
}
EmulationMenuTab.Graphics -> when (state.value.selectedAction) {
0 -> setRenderer("auto")
1 -> setRenderer("vulkan")
2 -> setRenderer("opengl")
3 -> setRenderer("software")
}
// Fixes is a registry-driven pane (its controls self-navigate), so it has
// no discrete action grid — nothing to activate here.
EmulationMenuTab.Fixes -> Unit
EmulationMenuTab.Performance -> when (state.value.selectedAction) {
0 -> updateSettings { it.copy(frameLimitEnable = !it.frameLimitEnable) }
1 -> setSpeed(it = state.value.settings.nominalSpeedPercent + 5)
2 -> setFrameSkip((state.value.settings.frameSkip + 1) % 6)
}
EmulationMenuTab.Controls -> when (state.value.selectedAction) {
0 -> editTouchControls()
1 -> toggleTouchControls()
}
EmulationMenuTab.Options -> when (state.value.selectedAction) {
0 -> updateSettings { it.copy(enablePatches = !it.enablePatches) }
1 -> updateSettings { it.copy(enableCheats = !it.enableCheats) }
2 -> updateSettings { it.copy(enableWideScreenPatches = !it.enableWideScreenPatches) }
3 -> updateSettings { it.copy(enableNoInterlacingPatches = !it.enableNoInterlacingPatches) }
}
EmulationMenuTab.Achievements -> when (state.value.selectedAction) {
0 -> requestToggleHardcore()
1 -> openAchievements()
}
}
}
fun resume() {
dismissHandler?.invoke() ?: resumeImmediately()
}
@@ -352,18 +299,6 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
state.value = state.value.copy(settings = updated)
}
private fun actionCount(tab: EmulationMenuTab): Int = when (tab) {
// MUST match SessionPane's action list length. This was 4 against a list of 5, so the pad
// could never reach Close at all.
EmulationMenuTab.Session -> 5
EmulationMenuTab.Graphics -> 4
EmulationMenuTab.Fixes -> 0
EmulationMenuTab.Performance -> 3
EmulationMenuTab.Controls -> 2
EmulationMenuTab.Options -> 5
EmulationMenuTab.Achievements -> 2
}
private fun Int.floorMod(modulus: Int): Int = ((this % modulus) + modulus) % modulus
}