From 48b4298cf61091f6b7d7764c1f111105457a1533 Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Sun, 2 Aug 2026 20:02:35 -0700 Subject: [PATCH] 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. --- .../com/armsx2/runtime/MainActivityRuntime.kt | 9 ++- .../java/com/armsx2/ui/home/HomeScreen.kt | 68 +++++++++++++++---- 2 files changed, 59 insertions(+), 18 deletions(-) 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 868d8f6eee..00ba390c1c 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 @@ -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 diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt index 77b3362294..bd684f0fdc 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt @@ -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(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 }