Fix Nova layouts and session drawer shortcuts (#1574)

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.
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-25 16:06:19 -04:00
1 parent 679e9476c3
commit 12eac3e9ee
6 files changed
+112 -41

No files matched your search

@@ -271,6 +271,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private val resumeKeysDown = mutableSetOf<Pair<Int, Int>>()
private val drawerShortcutKeysDown = mutableSetOf<Pair<Int, Int>>()
private var drawerOpen by mutableStateOf(false)
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(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) {
@@ -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 -> {
@@ -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),
)
}
}
@@ -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<StoreTab>, 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<String, Rect>() }
val rootBounds = remember { HashMap<String, Rect>() }
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<Rect?>(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
@@ -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(
@@ -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<Boolean> = ReflectionHelpers.getField(activity, "drawerOpen\$delegate")
val controller: MutableState<Boolean> = 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<Pair<Int, Int>> = ReflectionHelpers.getField(activity, "drawerShortcutKeysDown")
assertTrue(held.isEmpty())
}
}
@Test fun sessionMicrophoneCanBeTurnedOff() {
val changes = mutableListOf<Boolean>()
compose.setContent {