From 12eac3e9ee18dc61974e72a6ce7a7db4e70d97e8 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:06:19 -0400 Subject: [PATCH] Fix Nova layouts and session drawer shortcuts (#1574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve Nova’s compact layouts with scrolling store tabs and build details, remove the redundant Updates heading, preserve full portrait artwork, and add drawer PiP and Select/Start shortcuts for Guide/QAM. --- .../com/droiddeck/launcher/SessionActivity.kt | 15 ++++ .../droiddeck/launcher/ui/FrontEndSetup.kt | 12 +-- .../droiddeck/launcher/ui/SessionOverlay.kt | 16 ++-- .../com/droiddeck/launcher/ui/StoresPage.kt | 73 +++++++++++-------- .../com/droiddeck/launcher/ui/UpdatesPage.kt | 10 ++- .../launcher/ui/SessionSecondScreenTest.kt | 27 +++++++ 6 files changed, 112 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index b6689b3f0..9a5b1ddc9 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -271,6 +271,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } private val resumeKeysDown = mutableSetOf>() + private val drawerShortcutKeysDown = mutableSetOf>() private var drawerOpen by mutableStateOf(false) private var quitFrom by mutableStateOf(null) @@ -2071,6 +2072,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } return super.dispatchKeyEvent(event) } + val drawerShortcutKey = event.deviceId to event.keyCode + if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT || event.keyCode == KeyEvent.KEYCODE_BUTTON_START) && + ((drawerOpen && SessionState.mode == SessionService.MODE_STEAM) || drawerShortcutKey in drawerShortcutKeysDown)) { + if (event.action == KeyEvent.ACTION_DOWN) { + drawerShortcutKeysDown.add(drawerShortcutKey) + if (event.repeatCount == 0) { + releaseDrawerDirection() + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT) sendSteamGuide() else triggerSteamQam() + } + } else if (event.action == KeyEvent.ACTION_UP) { + drawerShortcutKeysDown.remove(drawerShortcutKey) + } + return true + } if (drawerOpen) { if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 69aa9cee0..1bf99f069 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -206,7 +207,6 @@ internal fun SetupPanel( runCatching { chipFocus[tab].requestFocus() } } } - val wide = !LocalNarrowPane.current val runtimeOk = s.ready && !s.busy && !s.removalPending val checks = listOf(gpuOk, runtimeOk, !limitBlocks, signedIn) val passing = checks.count { it } @@ -453,10 +453,12 @@ internal fun SetupPanel( ) } } - if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) { - Column(Modifier.weight(1f)) { look() } - Column(Modifier.weight(1f)) { device(); art(); download() } - } else { look(); device(); art(); download() } + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth >= 840.dp && !LocalNarrowPane.current) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { look() } + Column(Modifier.weight(1f)) { device(); art(); download() } + } else Column { look(); device(); art(); download() } + } } STORAGE_TAB -> StoragePanel(s, a) 4 -> { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index ef90316ea..ecf43901d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -426,6 +426,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont ) { Text(a.title ?: if (a.steam) stringResource(R.string.drawer_steam) else stringResource(R.string.drawer_desktop), fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (a.pipSupported) SteamActionPill( + stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt, focus.track(page, "pip"), + ) { host.open = null; a.onPip() } if (a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } var qamStartedOnPress by remember { mutableStateOf(false) } @@ -438,13 +441,13 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } } } - SteamActionPill(stringResource(R.string.drawer_steam_menu), Icons.Outlined.Home, focus.track(page, "steam")) { + SteamActionPill(stringResource(R.string.drawer_steam_menu), Icons.Outlined.Home, focus.track(page, "steam"), shortcut = "⧉") { host.open = null a.onSteamMenu.invoke() } SteamActionPill( stringResource(R.string.drawer_open_qam), Icons.Outlined.MoreHoriz, focus.track(page, "qam"), - interactionSource = qamInteraction, + interactionSource = qamInteraction, shortcut = "☰", onConfirm = { host.open = null; a.onQam.invoke() }, ) { host.open = null @@ -711,8 +714,6 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont a.pipAutoEnter, onChange = a.onPipAutoEnter) } Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (a.pipSupported) DrawerActionButton(stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt, - modifier = focus.track(page, "pip")) { host.open = null; a.onPip() } if (!a.isHomeApp) DrawerActionButton(stringResource(R.string.drawer_background), Icons.AutoMirrored.Outlined.ExitToApp, modifier = focus.track(page, "background")) { host.open = null; a.onBackground() } } @@ -818,6 +819,7 @@ private fun SteamActionPill( description: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onConfirm: (() -> Unit)? = null, + shortcut: String? = null, onClick: () -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -826,7 +828,7 @@ private fun SteamActionPill( val shape = RoundedCornerShape(22.dp) Box( contentAlignment = Alignment.Center, - modifier = modifier.size(width = 52.dp, height = 44.dp).semantics { contentDescription = description } + modifier = modifier.size(44.dp).semantics { contentDescription = description } .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(interactionSource) @@ -834,6 +836,10 @@ private fun SteamActionPill( .controllerConfirm(onClick = onConfirm ?: onClick), ) { Icon(icon, contentDescription = null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(20.dp)) + if (shortcut != null) Text( + shortcut, fontSize = 8.sp, lineHeight = 9.sp, color = colors.onSurfaceVariant, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 8.dp, bottom = 6.dp), + ) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index a08342743..23a996fb9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -53,6 +53,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable import androidx.compose.foundation.selection.selectable @@ -611,6 +613,7 @@ private fun RowScope.ArrangeStoreChip( ) { onToggleTab(store) } } +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun StoreChips( selected: String, focused: String?, active: Int, tabs: List, arranging: Boolean, @@ -620,7 +623,6 @@ private fun StoreChips( val pal = LocalPalette.current val keys = tabs.filter { it.shown }.map { it.store.id } + DOWNLOADS val bounds = remember { androidx.compose.runtime.mutableStateMapOf() } - val rootBounds = remember { HashMap() } val highlighted = focused ?: selected val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard && !arranging @@ -630,47 +632,59 @@ private fun StoreChips( val gap = 8.dp val gapPx = with(androidx.compose.ui.platform.LocalDensity.current) { gap.toPx() } val drag = remember { ChipDrag() } + val tabScroll = rememberScrollState() Box(Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - for (tab in tabs) { - val store = tab.store - if (arranging) { - val index = tabs.indexOfFirst { it.store == store } - key(store) { ArrangeStoreChip(tab, index, tabs.lastIndex, gapPx, drag, onFocusChip, onMoveTab, onToggleTab) } - } else if (tab.shown) { - val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true - key(store) { - StoreChip( - label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot, - modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() }, - lead = { StoreDot(store, signedIn) }, - description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out), - onFocused = onFocusChip, - ) { onStore(store) } + Box(Modifier.weight(1f).then(if (arranging) Modifier else Modifier.edgeFade(tabScroll).horizontalScroll(tabScroll))) { + Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically, + modifier = (if (arranging) Modifier.fillMaxWidth() else Modifier).focusGroup(), + ) { + for (tab in tabs) { + val store = tab.store + if (arranging) { + val index = tabs.indexOfFirst { it.store == store } + key(store) { ArrangeStoreChip(tab, index, tabs.lastIndex, gapPx, drag, onFocusChip, onMoveTab, onToggleTab) } + } else if (tab.shown) { + val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true + key(store) { + val reveal = remember { BringIntoViewRequester() } + LaunchedEffect(selected) { if (selected == store.id) reveal.bringIntoView() } + StoreChip( + label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot, + modifier = Modifier.bringIntoViewRequester(reveal).onGloballyPositioned { bounds[store.id] = it.boundsInParent() }, + lead = { StoreDot(store, signedIn) }, + description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out), + onFocused = onFocusChip, + ) { onStore(store) } + } + } } } + ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown && highlighted != DOWNLOADS) } if (arranging) { IconChip(Icons.Filled.Check, stringResource(R.string.stores_tabs_arrange_done)) { onArrangeDone() } } else { - StoreChip( - label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, - modifier = Modifier.onGloballyPositioned { - bounds[DOWNLOADS] = it.boundsInParent() - StoresMotion.downloadsChip = it.boundsInRoot() - }, - lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, - trail = if (active > 0) { { CountPill(active) } } else null, - description = stringResource(R.string.stores_tab_downloads), - onFocused = onFocusChip, - ) { onDownloads() } + Box { + StoreChip( + label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, + modifier = Modifier.onGloballyPositioned { + bounds[DOWNLOADS] = it.boundsInParent() + StoresMotion.downloadsChip = it.boundsInRoot() + }, + lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, + trail = if (active > 0) { { CountPill(active) } } else null, + description = stringResource(R.string.stores_tab_downloads), + onFocused = onFocusChip, + ) { onDownloads() } + ChipGlide(keys.indexOf(DOWNLOADS), bounds[DOWNLOADS], tint, visible = fillShown && highlighted == DOWNLOADS) + } var cogAt by remember { mutableStateOf(null) } Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } } } } - ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown) } } @@ -1323,7 +1337,8 @@ internal fun CardFace( Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.35f else 0.7f))) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip)) } else if (art != null) AsyncImage( - model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), + model = art, contentDescription = null, contentScale = if (capsule) ContentScale.Fit else ContentScale.Crop, + modifier = Modifier.matchParentSize().padding(bottom = if (capsule) CardStrip else 0.dp), onSuccess = { r -> val drawn = r.result.drawable if (capsule && drawn.intrinsicWidth > 0 && drawn.intrinsicHeight > 0) landscape = drawn.intrinsicWidth > drawn.intrinsicHeight * 0.95f diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index a44f5ca19..c11d5874d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable @@ -88,6 +89,7 @@ class UpdatesActions( val onDismissPermission: () -> Unit = {}, ) +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { val u = s.updates @@ -95,10 +97,14 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie val me = remember { AppUpdates.installed() } val colors = MaterialTheme.colorScheme Column(modifier = modifier) { - PageHeader(stringResource(R.string.upd_title)) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { Text( stringResource(R.string.upd_build, me.version, s.buildLabel), fontSize = 12.5.sp, color = colors.onSurfaceVariant, - maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(top = 6.dp), + maxLines = 1, modifier = Modifier.weight(1f) + .basicMarquee(iterations = Int.MAX_VALUE, initialDelayMillis = 700, velocity = 40.dp), ) val checked = u.catalog?.checkedAt?.takeIf { it > 0 } if (!LocalNarrowPane.current) Text( diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt index 735632e61..d4c5aa4b2 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt @@ -98,6 +98,33 @@ class SessionSecondScreenTest { } } + @Test + @Config(sdk = [34], shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) + fun drawerShortcutsConsumeRepeatsAndReleasesAfterClosing() { + val device = InputDeviceBuilder.newBuilder().setId(43).setName("Drawer shortcut test pad") + .setDescriptor("drawer-shortcut-pad").setExternal(true).setSources(InputDevice.SOURCE_GAMEPAD).build() + shadowOf(context.getSystemService(InputManager::class.java)).addInputDevice(device) + SessionState.mode = SessionService.MODE_STEAM + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val open: MutableState = ReflectionHelpers.getField(activity, "drawerOpen\$delegate") + val controller: MutableState = ReflectionHelpers.getField(activity, "drawerControllerActive\$delegate") + controller.value = true + for (button in listOf(KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START)) { + fun event(action: Int, repeat: Int = 0) = KeyEvent(0L, 0L, action, button, repeat, 0, + device.id, 0, 0, InputDevice.SOURCE_GAMEPAD) + open.value = true + assertTrue(activity.dispatchKeyEvent(event(KeyEvent.ACTION_DOWN))) + assertEquals(false, open.value) + open.value = true + assertTrue(activity.dispatchKeyEvent(event(KeyEvent.ACTION_DOWN, 1))) + assertEquals(true, open.value) + open.value = false + assertTrue(activity.dispatchKeyEvent(event(KeyEvent.ACTION_UP))) + val held: Set> = ReflectionHelpers.getField(activity, "drawerShortcutKeysDown") + assertTrue(held.isEmpty()) + } + } + @Test fun sessionMicrophoneCanBeTurnedOff() { val changes = mutableListOf() compose.setContent {