From 7bb66de192eebae47fc1f8ee078a8be15530f8f5 Mon Sep 17 00:00:00 2001 From: FX1922 <120828496+FX192@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:13:31 +0300 Subject: [PATCH] fix: keep controller focus after installing a boot movie; Xbox and itch artwork covered (#1229) --- .../droiddeck/launcher/ui/AddedGameEditor.kt | 31 +++++++++++---- .../droiddeck/launcher/ui/BootMovieSection.kt | 38 +++++++++++++++---- .../launcher/ui/LogoPositionDialog.kt | 12 ++++-- .../launcher/ui/StoresInstallDialog.kt | 2 +- .../launcher/frontend/StoreGameArtTest.kt | 2 +- docs/development/added-games.md | 2 +- 6 files changed, 65 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt index cec6c7a42..3d86510f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt @@ -40,6 +40,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.input.InputMode import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import android.content.Context @@ -502,7 +508,7 @@ private fun ArtChooserDialog( sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER } val first = remember { FocusRequester() } - LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } } + LaunchedEffect(options != null) { focusWithinFrames({ false }) { first } } val apply: (AddedGameArt.Option) -> Unit = { option -> scope.launch { val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) } @@ -624,14 +630,23 @@ private fun SgdbSearchDialog( } } LaunchedEffect(Unit) { search() } - AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) { + val firstItem = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val leaveField = { focusManager.clearFocus(); keyboardController?.hide() } + val keyboard = LocalInputModeManager.current.inputMode == InputMode.Keyboard + LaunchedEffect(picked?.id, matches?.size, images.size, busy) { + val ready = if (picked != null) images.isNotEmpty() else !matches.isNullOrEmpty() + if (keyboard && !busy && ready) focusSoon { firstItem } + } + AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp, modifier = Modifier.pointerInput(Unit) { detectTapGestures { leaveField() } }) { DialogTitleRow(stringResource(R.string.art_sgdb_title, slotName), onClose = { onCloseAll() }, leading = { SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back) }) androidx.compose.material3.OutlinedTextField( query, { query = it }, singleLine = true, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.art_sgdb_search_label)) }, - keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }), + keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { leaveField(); search() }), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search), ) val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp @@ -643,24 +658,24 @@ private fun SgdbSearchDialog( val chosenGame = picked val list = matches.orEmpty() if (chosenGame == null) { - for (match in list) key(match.id) { PickerRow(null, match.name, null) { showImages(match) } } + list.forEachIndexed { i, match -> key(match.id) { PickerRow(null, match.name, null, focus = if (i == 0) firstItem else null) { leaveField(); showImages(match) } } } if (matches != null && list.isEmpty() && !busy && status == SteamGridDb.Status.OK) { Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp)) } } else { Text(chosenGame.name, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - for (option in images) key(option.full) { + images.forEachIndexed { i, option -> key(option.full) { ArtOption( - slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = null, + slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = if (i == 0) firstItem else null, description = "${artSourceLabel(AddedGameArt.SGDB)} $slotName", ) { onPick(option) } - } + } } } if (images.isEmpty() && !busy && status == SteamGridDb.Status.OK) { Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp)) } - if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null } + if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { leaveField(); picked = null } } if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt index 3878d274b..a4227d4c2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt @@ -69,6 +69,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.InputMode import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalInputModeManager @@ -558,13 +559,37 @@ private fun repoInfo(post: SteamDeckRepo.Post): String = listOf(post.author, "♥ ${post.likes}", "⇩ ${post.downloads}", if (post.seconds > 0) stringResource(R.string.boot_movie_seconds, post.seconds) else "") .filter { it.isNotEmpty() }.joinToString(" · ") +private const val AFTER_OPEN = 3 +private const val AFTER_INSTALL = 1 +private const val AFTER_USE = 2 + +/** Install and Use are different buttons in turn, and the button is disabled while installing; it takes focus back for the controller. */ @Composable -private fun RepoAction(installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit) { - if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = modifier, onClick = {}) - else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = modifier, onClick = onUse) +private fun RepoAction( + installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit, + takeFocus: Boolean = false, +) { + val requester = remember { FocusRequester() } + val focused = remember { booleanArrayOf(false) } + var pending by remember { mutableIntStateOf(if (takeFocus) AFTER_OPEN else 0) } + val inputMode = LocalInputModeManager.current + LaunchedEffect(installed, inUse, busy, percent != null, pending) { + if (pending == 0) return@LaunchedEffect + if (inputMode.inputMode != InputMode.Keyboard) { pending = 0; return@LaunchedEffect } + focusWithinFrames({ focused[0] }) { requester } + when (pending) { + AFTER_OPEN -> pending = 0 + AFTER_INSTALL -> if (installed && focused[0]) pending = 0 + AFTER_USE -> if (inUse && focused[0]) pending = 0 + else -> Unit + } + } + val m = modifier.focusRequester(requester).onFocusChanged { focused[0] = it.hasFocus || it.isFocused } + if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = m, onClick = {}) + else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = m, onClick = { pending = AFTER_USE; onUse() }) else SecondaryButton( if (percent != null) stringResource(R.string.boot_movie_converting, percent) else stringResource(R.string.boot_movie_repo_install), - enabled = !busy, compact = true, modifier = modifier, onClick = onInstall, + enabled = !busy, compact = true, modifier = m, onClick = { pending = AFTER_INSTALL; onInstall() }, ) } @@ -576,9 +601,6 @@ private fun PreviewDialog( ) { val shown = rememberShown(onDismiss) val close = { shown.targetState = false } - val action = remember { FocusRequester() } - val inputMode = LocalInputModeManager.current - LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { action } } val maxVideoHeight = (LocalConfiguration.current.screenHeightDp - 200).coerceAtLeast(120).dp AppDialog(shown, close, "boot-movie-preview", wide = false, maxWidth = 620.dp) { DialogTitleRow(post.title, close) @@ -586,7 +608,7 @@ private fun PreviewDialog( MoviePreview(post.video, Modifier.heightIn(max = maxVideoHeight).aspectRatio(16f / 9f)) } Text(repoInfo(post), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth().focusRequester(action), onInstall, onUse) + RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse, takeFocus = true) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt index 7e42aab51..2ddbdbe28 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LogoPositionDialog.kt @@ -3,6 +3,9 @@ package com.droiddeck.launcher.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.runtime.LaunchedEffect import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -105,7 +108,7 @@ private fun LogoPreview(hero: File?, logo: File?, value: LogoPosition.Value) { } @Composable -private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) { +private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) { val pal = LocalPalette.current val colors = MaterialTheme.colorScheme val src = remember { MutableInteractionSource() } @@ -113,7 +116,7 @@ private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) { val shape = RoundedCornerShape(8.dp) Box( contentAlignment = Alignment.Center, - modifier = Modifier.clip(shape) + modifier = Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(shape) .background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f)) .border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape) .glideBorder(hot, shape, pal.signal) @@ -140,6 +143,9 @@ internal fun LogoPositionDialog( val shown = rememberShown(onBack) val back = { shown.targetState = false } var value by remember { mutableStateOf(start ?: LogoPosition.DEFAULT) } + val startPin = remember { value.pin } + val first = remember { FocusRequester() } + LaunchedEffect(Unit) { focusSoon { first } } var failed by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) } val save: (LogoPosition.Value?) -> Unit = { next -> @@ -172,7 +178,7 @@ internal fun LogoPositionDialog( ) Text(stringResource(R.string.logo_pos_position), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin) { value = value.copy(pin = pin) } + for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin, if (pin == startPin) first else null) { value = value.copy(pin = pin) } } ValueSlider( value.widthPct, LogoPosition.MIN_PCT..LogoPosition.MAX_PCT, step = 1, enabled = true, label = stringResource(R.string.logo_pos_size), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt index 565b5cc9a..435cf0957 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt @@ -65,7 +65,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) { ) Text( stringResource(R.string.common_cancel), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, - modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).padding(horizontal = 10.dp, vertical = 6.dp), + modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).controllerConfirm(onClick = close).padding(horizontal = 10.dp, vertical = 6.dp), ) } } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/StoreGameArtTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/StoreGameArtTest.kt index a0e84775d..448cca6a8 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/StoreGameArtTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/StoreGameArtTest.kt @@ -36,7 +36,7 @@ class StoreGameArtTest { } @Test fun anArtChoiceOnAStoreGameIsKeptShownAndResetsWithoutTouchingItsTitle() { - for ((store, id, title) in listOf(Triple(Store.GOG, "1207658924", "Heroes III"), Triple(Store.EPIC, "abc", "Celeste"), Triple(Store.AMAZON, "amzn1.x", "Yakuza 0"))) { + for ((store, id, title) in listOf(Triple(Store.GOG, "1207658924", "Heroes III"), Triple(Store.EPIC, "abc", "Celeste"), Triple(Store.AMAZON, "amzn1.x", "Yakuza 0"), Triple(Store.XBOX, "9NBLGGH4X1", "Forza Horizon"), Triple(Store.ITCH, "12345", "Night in the Woods"))) { val folder = install(store, id, title) val image = File(tmp.root, "$id.png").apply { writeBytes(ByteArray(9) { 1 }) } val option = AddedGameArt.Option(AddedGameArt.FILE, image.path, image.path) diff --git a/docs/development/added-games.md b/docs/development/added-games.md index 9e1566dd4..c39ae8d14 100644 --- a/docs/development/added-games.md +++ b/docs/development/added-games.md @@ -95,7 +95,7 @@ scan (`Library.launchableGames`) compares the folder with what was last written (`grid-` in the game's art folder): a file Steam changed becomes the choice (source Steam), a file removed in Steam drops it, and files the shortcuts writer placed itself (`.droiddeck-art.json`) are not mistaken for choices. A chosen image is stored as -`chosen--