Android: replace the per-game sheet with a bottom panel, and bind X to it

The per-game menu was a ModalBottomSheet -- its own focused Android
window, so none of its six rows could be reached with a pad. It keeps
its look: still rises from the bottom edge, full width, rounded at the
top, grab-handle silhouette intact. Swipe-to-dismiss is the one thing
genuinely lost; B and a tap on the scrim both close it.

X on a highlighted cover now opens that menu instead of jumping
straight to the game's settings. The shortcut was not wrong so much as
narrow: settings is one of the menu's six rows, and while the menu was a
sheet the other five -- play, per-game BIOS, pin to launcher, hide, drop
from Recents -- had no controller route at all. Anyone without a
touchscreen simply could not reach them. Settings is still one press
away as the second row, so the shortcut costs one A to keep.

The menu's visibility is HomeScreen's own composable state, so the input
controller takes a callback for it rather than trying to hold it, the
same shape as its existing drawer hook.
This commit is contained in:
Brian Degenhardt
2026-08-02 20:49:36 -07:00
parent 8c50d9de6b
commit 48b4298cf6
2 changed files with 59 additions and 18 deletions
@@ -2947,13 +2947,16 @@ open class MainActivityRuntime : ComponentActivity() {
!com.armsx2.navigation.UiNavigator.drawerOpen.value &&
com.armsx2.ui.home.HomeInputController.active()
) {
// Square button (or the Menu hotkey) opens settings for the
// highlighted cover — the controller equivalent of long-press.
// Square button (or the Menu hotkey) opens the per-game MENU for the
// highlighted cover — the controller equivalent of long-press, and the same
// menu a touch user gets. It used to jump straight to that game's settings,
// which is one of the menu's six rows; the other five had no controller route
// at all while the menu was a bottom sheet.
if (ControllerMappings.hotkeyFor(kc) == ControllerMappings.SysHotkey.MENU ||
kc == KeyEvent.KEYCODE_BUTTON_X
) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0)
com.armsx2.ui.home.HomeInputController.openSelectedSettings()
com.armsx2.ui.home.HomeInputController.openSelectedGameMenu()
return true
}
// #267: Y (Triangle) opens the library SEARCH — the requested
@@ -1,5 +1,6 @@
package com.armsx2.ui.home
import androidx.compose.foundation.layout.heightIn
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -54,7 +55,6 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -159,7 +159,7 @@ fun HomeScreen(
}
LaunchedEffect(directories, nativeReady) { viewModel.load(directories, nativeReady) }
DisposableEffect(viewModel, onOpenMenu) {
HomeInputController.bind(viewModel, onOpenMenu)
HomeInputController.bind(viewModel, onOpenMenu, onOpenGameMenu = { menuGame = it })
onDispose { HomeInputController.unbind(viewModel) }
}
@@ -697,11 +697,34 @@ fun HomeScreen(
val menuCRC by androidx.compose.runtime.produceState<String?>(initialValue = null, game.uri) {
value = com.armsx2.DiscIdentity.resolve(game.uri, game.serial) ?: ""
}
ModalBottomSheet(onDismissRequest = { menuGame = null }) {
// A bottom-aligned panel rather than a ModalBottomSheet: that is its own focused
// Android window, so every row in here was unreachable by pad. Same look — it still
// rises from the bottom edge, full width, rounded at the top. Swipe-to-dismiss is the
// one thing lost; B and a tap on the scrim both close it.
com.armsx2.ui.common.PadModal(
key = "game-menu",
onDismiss = { menuGame = null },
alignment = Alignment.BottomCenter,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(start = 8.dp, end = 8.dp, bottom = 20.dp),
Modifier.fillMaxWidth().heightIn(max = 520.dp).verticalScroll(rememberScrollState()).padding(start = 8.dp, end = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Keeps the sheet's grab-handle silhouette now that the real one is gone.
Box(Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 2.dp), contentAlignment = Alignment.Center) {
Box(
Modifier
.size(width = 32.dp, height = 4.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)),
)
}
Text(
game.displayTitle(EnglishTitles.enabled.value),
style = MaterialTheme.typography.titleLarge,
@@ -724,17 +747,17 @@ fun HomeScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp),
)
GameMenuAction("", str("action.play")) {
GameMenuAction("", str("action.play"), "game-menu.play") {
menuGame = null
viewModel.launch(game)
}
GameMenuAction("", str("action.settings")) {
GameMenuAction("", str("action.settings"), "game-menu.settings") {
menuGame = null
onOpenGameSettings(game)
}
// Per-game BIOS: open the BIOS manager scoped to THIS game (no need to load it),
// since the BIOS manager isn't reachable from the in-game menu.
GameMenuAction("📀", str("bios.perGame.menu")) {
GameMenuAction("📀", str("bios.perGame.menu"), "game-menu.bios") {
menuGame = null
com.armsx2.navigation.UiNavigator.navigate(com.armsx2.navigation.AppRoute.BiosManager(game))
}
@@ -742,7 +765,7 @@ fun HomeScreen(
// rebuilt, leaving HomeShortcuts with no call site at all (issue #335).
// pin() returns false only when the launcher can't pin — surface that.
val addToHomeFailed = str("games.addToHome.unsupported")
GameMenuAction("📌", str("games.addToHome")) {
GameMenuAction("📌", str("games.addToHome"), "game-menu.pin") {
menuGame = null
if (!com.armsx2.HomeShortcuts.pin(context, game))
Toast.makeText(context, addToHomeFailed, Toast.LENGTH_LONG).show()
@@ -750,26 +773,29 @@ fun HomeScreen(
// Only offered when the game is actually in Recently Played — this drops
// just this one entry, unlike the library-wide "Show Recently Played" toggle.
if (state.recentGames.any { it.uri == game.uri }) {
GameMenuAction("🕐", str("games.removeRecent")) {
GameMenuAction("🕐", str("games.removeRecent"), "game-menu.recent") {
viewModel.removeFromRecent(game)
menuGame = null
}
}
val hidden = com.armsx2.HiddenGames.isHidden(game)
GameMenuAction(if (hidden) "" else "🚫", str(if (hidden) "games.unhide" else "games.hide")) {
GameMenuAction(if (hidden) "" else "🚫", str(if (hidden) "games.unhide" else "games.hide"), "game-menu.hide") {
viewModel.setHidden(game, !hidden)
menuGame = null
}
}
}
}
}
}
@Composable
private fun GameMenuAction(glyph: String, label: String, onClick: () -> Unit) {
private fun GameMenuAction(glyph: String, label: String, id: String, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.controllerFocusable(controllerId = id, shape = RoundedCornerShape(18.dp), onConfirm = onClick),
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)),
@@ -1265,7 +1291,12 @@ object HomeInputController {
* time the library (re)opens it starts at the top again. */
var userNavigated = false
fun bind(viewModel: HomeViewModel, onOpenMenu: () -> Unit) {
/** Opens the per-game menu for a cover. Registered by HomeScreen because the menu's
* visibility is its own composable state, not something this object can hold. */
private var openGameMenu: ((GameInfo) -> Unit)? = null
fun bind(viewModel: HomeViewModel, onOpenMenu: () -> Unit, onOpenGameMenu: (GameInfo) -> Unit) {
openGameMenu = onOpenGameMenu
owner = viewModel
openMenu = onOpenMenu
userNavigated = false
@@ -1275,6 +1306,7 @@ object HomeInputController {
if (owner === viewModel) {
owner = null
openMenu = null
openGameMenu = null
scrollVelocity.floatValue = 0f
zone.value = HomeZone.Grid
}
@@ -1399,9 +1431,15 @@ object HomeInputController {
return true
}
fun openSelectedSettings(): Boolean {
/** X (Square) on the highlighted cover: open its menu — the controller equivalent of a
* long-press, and the same menu touch gets. It replaced a direct jump to that game's
* settings, which reached exactly one of the menu's rows and hid the other five (per-game
* BIOS, pin to launcher, hide, drop from Recents, play) from anyone without a touchscreen.
* Settings is still one press away, as the second row. */
fun openSelectedGameMenu(): Boolean {
val game = owner?.selectedGame() ?: return false
com.armsx2.navigation.UiNavigator.navigate(com.armsx2.navigation.AppRoute.Settings(game = game))
val open = openGameMenu ?: return false
open(game)
return true
}