diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt index 9ce92870d..5722f0272 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -272,7 +272,7 @@ internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (St /** Open from the first frame; [onDismiss] once the closing animation has run. */ @Composable -private fun rememberShown(onDismiss: () -> Unit): MutableTransitionState { +internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState { val shown = remember { MutableTransitionState(false).apply { targetState = true } } LaunchedEffect(shown.currentState, shown.isIdle) { if (shown.isIdle && !shown.currentState && !shown.targetState) onDismiss() } return shown @@ -283,7 +283,7 @@ private fun rememberShown(onDismiss: () -> Unit): MutableTransitionState, close: () -> Unit, label: String, wide: Boolean, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { @@ -328,7 +328,7 @@ private fun AppDialog( /** The eyebrow and title, with [trailing] (the tabs) beside them when there is room, else under them. */ @Composable -private fun DialogHeader(eyebrow: String, title: String, trailing: (@Composable () -> Unit)? = null) { +internal fun DialogHeader(eyebrow: String, title: String, trailing: (@Composable () -> Unit)? = null) { val heading = @Composable { Column { Eyebrow(eyebrow) @@ -381,7 +381,7 @@ private fun Panel(title: String, content: @Composable ColumnScope.() -> Unit) { /** Why the main button is off (or nothing), then Cancel and the main button, at the card's foot. */ @Composable -private fun DialogFooter(note: String?, confirm: String, enabled: Boolean, onCancel: () -> Unit, onConfirm: () -> Unit) { +internal fun DialogFooter(note: String?, confirm: String, enabled: Boolean, onCancel: () -> Unit, onConfirm: () -> Unit) { val lineColor = LocalPalette.current.line Rise(2) { Row( @@ -396,7 +396,7 @@ private fun DialogFooter(note: String?, confirm: String, enabled: Boolean, onCan } @Composable -private fun Small(text: String, error: Boolean = false) = +internal fun Small(text: String, error: Boolean = false) = Text(text, fontSize = 12.sp, lineHeight = 16.sp, color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) private fun hideKeyboard(view: View) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt index c871ac847..37c1ddd8f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -1,19 +1,41 @@ package com.droiddeck.launcher.ui -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.core.GameEnvironmentOptions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.KeyboardType import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.session.GameEnvironmentStore import com.droiddeck.launcher.session.SessionPrefs @@ -21,13 +43,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +// Environment variables for Proton games: one shared profile and one per game, in the app's own +// dialog, with each variable's actions in a menu off its value as everywhere else. + @Composable fun GameEnvironmentRow(modifier: Modifier = Modifier) { var open by remember { mutableStateOf(false) } SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) { - TextButton(onClick = { open = true }, modifier = modifier.controllerConfirm { open = true }) { - Text(stringResource(R.string.game_env_edit)) - } + SecondaryButton(stringResource(R.string.game_env_edit), modifier = modifier) { open = true } } if (open) GameEnvironmentEditor { open = false } } @@ -36,12 +59,13 @@ fun GameEnvironmentRow(modifier: Modifier = Modifier) { private fun GameEnvironmentEditor(onClose: () -> Unit) { val context = LocalContext.current val coroutine = rememberCoroutineScope() + val shown = rememberShown(onClose) + val close = { shown.targetState = false } var config by remember { mutableStateOf(null) } var games by remember { mutableStateOf(emptyList>()) } var scope by remember { mutableStateOf("") } - var appId by remember { mutableStateOf("") } - var scopeMenu by remember { mutableStateOf(false) } - var addMenu by remember { mutableStateOf(false) } + var otherId by remember { mutableStateOf(null) } + var menu by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(false) } var editing by remember { mutableStateOf?>(null) } @@ -62,69 +86,88 @@ private fun GameEnvironmentEditor(onClose: () -> Unit) { busy = false } } - AlertDialog( - onDismissRequest = { if (!busy) onClose() }, - title = { Text(stringResource(R.string.game_env_title)) }, - confirmButton = { TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.game_env_close)) } }, - text = { - Column(Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(stringResource(R.string.game_env_hint)) - Text(stringResource(R.string.game_env_defaults), style = MaterialTheme.typography.bodySmall) - if (error) Text(stringResource(R.string.game_env_error), color = MaterialTheme.colorScheme.error) - if (config == null && !error) Text(stringResource(R.string.game_env_loading)) - val current = config - if (current != null) { - val profileName = if (scope.isEmpty()) stringResource(R.string.game_env_shared) - else games.firstOrNull { it.first == scope }?.second ?: stringResource(R.string.game_env_profile, scope) - Text(stringResource(R.string.game_env_scope), style = MaterialTheme.typography.labelMedium) - Box { - TextButton(onClick = { scopeMenu = true }, enabled = !busy) { Text(profileName) } - DropdownMenu(expanded = scopeMenu, onDismissRequest = { scopeMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) { - DropdownMenuItem(text = { Text(stringResource(R.string.game_env_shared)) }, onClick = { scope = ""; scopeMenu = false }) - val profiles = (games + current.games.keys.filter { id -> games.none { it.first == id } }.map { it to it }).distinctBy { it.first } - for ((id, name) in profiles) DropdownMenuItem(text = { Text(name) }, onClick = { scope = id; scopeMenu = false }) - } + val current = config + val shared = stringResource(R.string.game_env_shared) + val profileName = if (scope.isEmpty()) shared + else games.firstOrNull { it.first == scope }?.second ?: stringResource(R.string.game_env_profile, scope) + + AppDialog(shown, close, "gameEnv", wide = false) { + DialogHeader(stringResource(R.string.game_env_eyebrow), stringResource(R.string.game_env_title)) + Small(stringResource(R.string.game_env_applies)) + // Which profile: everything shared, or one game's. + Panel { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text(stringResource(R.string.game_env_scope), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f)) + Box { + ValueChip(profileName, menu == "scope", modifier = Modifier.widthIn(max = 260.dp)) { + menu = if (menu == "scope") null else "scope" } - OutlinedTextField(value = appId, onValueChange = { appId = it }, singleLine = true, enabled = !busy, - label = { Text(stringResource(R.string.game_env_app_id)) }, modifier = Modifier.fillMaxWidth()) - TextButton(onClick = { scope = appId.toLong().toString(); appId = "" }, enabled = !busy && appId.isNotEmpty() && GameEnvironment.validScope(appId)) { - Text(stringResource(R.string.game_env_open)) - } - Text(stringResource(R.string.game_env_overrides), style = MaterialTheme.typography.bodySmall) - val entries = GameEnvironment.effective(current, preset, scope).toSortedMap() - if (entries.isEmpty()) Text(stringResource(R.string.game_env_empty)) - for ((name, value) in entries) { - HorizontalDivider() - Text(name, style = MaterialTheme.typography.titleSmall) - Text(value ?: stringResource(R.string.game_env_unset)) - if (!current.entries(scope).containsKey(name)) Text(stringResource(R.string.game_env_inherited), style = MaterialTheme.typography.labelSmall) - Row { - TextButton(onClick = { editing = name to (value ?: "") }, enabled = !busy) { Text(stringResource(R.string.game_env_edit)) } - if (value != null) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) + (name to null))) }, enabled = !busy) { - Text(stringResource(R.string.game_env_remove)) - } - } - if (current.entries(scope).containsKey(name)) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) - name)) }, enabled = !busy) { - Text(stringResource(R.string.game_env_restore)) - } - } - Box { - TextButton(onClick = { addMenu = true }, enabled = !busy) { Text(stringResource(R.string.game_env_add)) } - DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) { - DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = { editing = "" to ""; addMenu = false }) - for (option in GameEnvironmentOptions.all) DropdownMenuItem(text = { Text(option.name) }, onClick = { editing = option.name to option.value; addMenu = false }) - } - } - TextButton(onClick = { save(current.withEntries(scope, emptyMap())) }, enabled = !busy && current.entries(scope).isNotEmpty()) { - Text(stringResource(R.string.game_env_reset)) + AnchoredMenu(menu == "scope", onDismiss = { if (menu == "scope") menu = null }, title = stringResource(R.string.game_env_scope)) { first -> + MenuItem(shared, checked = scope.isEmpty(), focusRequester = first) { scope = ""; otherId = null; menu = null } + val profiles = (games + current?.games?.keys.orEmpty().filter { id -> games.none { it.first == id } }.map { it to it }).distinctBy { it.first } + for ((id, name) in profiles) MenuItem(name, checked = scope == id) { scope = id; otherId = null; menu = null } + MenuItem(stringResource(R.string.game_env_other), checked = false) { otherId = ""; menu = null } } } - if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) } - }, - ) + } + otherId?.let { id -> + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + AdbTextField( + id, { otherId = it.filter(Char::isDigit) }, stringResource(R.string.game_env_app_id), KeyboardType.Number, ImeAction.Done, + modifier = Modifier.weight(1f), compact = true, + onDone = { if (GameEnvironment.validScope(id)) { scope = id.toLong().toString(); otherId = null } }, + ) + SecondaryButton(stringResource(R.string.game_env_open), enabled = id.isNotEmpty() && GameEnvironment.validScope(id)) { + scope = id.toLong().toString(); otherId = null + } + } + } + if (error) Small(stringResource(R.string.game_env_error), error = true) + if (current == null && !error) Small(stringResource(R.string.game_env_loading)) + if (current != null) { + val own = current.entries(scope) + val entries = GameEnvironment.effective(current, preset, scope).toSortedMap() + if (entries.isEmpty()) Small(stringResource(R.string.game_env_empty)) + else Panel { + entries.entries.forEachIndexed { i, (name, value) -> + if (i > 0) Box(Modifier.fillMaxWidth().padding(horizontal = 14.dp).heightIn(min = 1.dp, max = 1.dp).background(LocalPalette.current.line)) + VariableRow( + name, value, inherited = !own.containsKey(name), open = menu == "var:$name", enabled = !busy, + onToggle = { menu = if (menu == "var:$name") null else "var:$name" }, onDismiss = { if (menu == "var:$name") menu = null }, + onEdit = { menu = null; editing = name to (value ?: "") }, + // A value set here or inherited can be switched off for this profile; one set here can go back to inheriting. + onUnset = if (value != null) ({ menu = null; save(current.withEntries(scope, own + (name to null))) }) else null, + onRestore = if (own.containsKey(name)) ({ menu = null; save(current.withEntries(scope, own - name)) }) else null, + shared = scope.isEmpty(), + ) + } + } + } + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + // Footer: add a variable, reset the profile, done. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 4.dp)) { + Box { + SecondaryButton(stringResource(R.string.game_env_add), enabled = current != null && !busy) { menu = if (menu == "add") null else "add" } + AnchoredMenu(menu == "add", onDismiss = { if (menu == "add") menu = null }, title = stringResource(R.string.game_env_add)) { first -> + for ((i, option) in GameEnvironmentOptions.all.withIndex()) MenuItem(option.name, checked = false, focusRequester = if (i == 0) first else null) { + menu = null; editing = option.name to option.value + } + MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editing = "" to "" } + } + } + if (current != null && current.entries(scope).isNotEmpty()) FocusText(stringResource(R.string.game_env_reset), LocalPalette.current.signal) { + save(current.withEntries(scope, emptyMap())) + } + Spacer(Modifier.weight(1f)) + PrimaryButton(stringResource(R.string.game_env_done), enabled = !busy, onClick = close) + } + } editing?.let { (name, value) -> - VariableDialog(name, value, onDismiss = { editing = null }) { key, content -> + VariableDialog(name, value, profileName, onDismiss = { editing = null }) { key, content -> config?.let { val entries = it.entries(scope).toMutableMap() if (name.isNotEmpty() && name != key) entries[name] = null @@ -136,99 +179,111 @@ private fun GameEnvironmentEditor(onClose: () -> Unit) { } } +/** The panel the profile and the variables sit in, as a settings group does. */ @Composable -private fun VariableDialog(initialName: String, initialValue: String, onDismiss: () -> Unit, onSave: (String, String) -> Unit) { +private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) = + Column(Modifier.fillMaxWidth().clip(Shape14).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line, Shape14), content = content) + +/** One variable: its name, what it is set to (and whether that comes from the shared profile), and a menu of what to do with it. */ +@Composable +private fun VariableRow( + name: String, value: String?, inherited: Boolean, open: Boolean, enabled: Boolean, shared: Boolean, + onToggle: () -> Unit, onDismiss: () -> Unit, onEdit: () -> Unit, onUnset: (() -> Unit)?, onRestore: (() -> Unit)?, +) { + val colors = MaterialTheme.colorScheme + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Column(Modifier.weight(1f)) { + Text(name, fontSize = 13.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (inherited) Text(stringResource(if (shared) R.string.game_env_default else R.string.game_env_inherited), fontSize = 12.sp, color = colors.onSurfaceVariant) + } + Box { + ValueChip(value ?: stringResource(R.string.game_env_off), open, enabled = enabled, modifier = Modifier.widthIn(max = 200.dp), onClick = onToggle) + AnchoredMenu(open, onDismiss = onDismiss, title = name) { first -> + MenuItem(stringResource(R.string.game_env_change), checked = false, focusRequester = first, onClick = onEdit) + if (onUnset != null) MenuItem(stringResource(if (shared) R.string.game_env_remove else R.string.game_env_unset), checked = false, onClick = onUnset) + if (onRestore != null) MenuItem(stringResource(if (shared) R.string.game_env_restore_default else R.string.game_env_restore), checked = false, onClick = onRestore) + } + } + } +} + +@Composable +private fun VariableDialog(initialName: String, initialValue: String, profile: String, onDismiss: () -> Unit, onSave: (String, String) -> Unit) { + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } var name by remember { mutableStateOf(initialName) } var value by remember { mutableStateOf(initialValue) } val valid = GameEnvironment.validName(name) && GameEnvironment.validValue(value) - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.game_env_edit)) }, - confirmButton = { TextButton(onClick = { onSave(name, value) }, enabled = valid) { Text(stringResource(R.string.game_env_save)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.game_env_cancel)) } }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - VariableNamePicker(name) { selected -> - name = selected - value = GameEnvironmentOptions.find(selected)?.value.orEmpty() - } - VariableValueEditor(name, value) { value = it } - GameEnvironmentOptions.find(name)?.takeIf { it.detail != 0 }?.let { - Text(stringResource(it.detail), style = MaterialTheme.typography.bodySmall) - } - if (!GameEnvironment.supported(name)) Text(stringResource(R.string.game_env_unused)) - if (!valid && name.isNotEmpty()) Text(stringResource(R.string.game_env_invalid), color = MaterialTheme.colorScheme.error) - } - }, - ) + val option = GameEnvironmentOptions.find(name) + AppDialog(shown, close, "gameEnvVar", wide = false) { + DialogHeader(profile, stringResource(if (initialName.isEmpty()) R.string.game_env_add else R.string.game_env_change_title)) + VariableNamePicker(name) { selected -> + name = selected + value = GameEnvironmentOptions.find(selected)?.value.orEmpty() + } + VariableValueEditor(name, value) { value = it } + if (!GameEnvironment.supported(name)) Small(stringResource(R.string.game_env_unused)) + if (!valid && name.isNotEmpty()) Small(stringResource(R.string.game_env_invalid), error = true) + DialogFooter( + note = option?.takeIf { it.detail != 0 }?.let { stringResource(it.detail) }, + confirm = stringResource(R.string.game_env_save), enabled = valid, onCancel = close, + ) { onSave(name, value); close() } + } } @Composable private fun VariableNamePicker(name: String, onChange: (String) -> Unit) { - var expanded by remember { mutableStateOf(false) } + var open by remember { mutableStateOf(false) } var custom by remember { mutableStateOf(name.isEmpty() || GameEnvironmentOptions.find(name) == null) } - Text(stringResource(R.string.game_env_name), style = MaterialTheme.typography.labelMedium) - Box { - OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { - Text(if (custom) stringResource(R.string.game_env_custom) else name) - } - DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) { - DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = { - custom = true; onChange(""); expanded = false - }) - for (option in GameEnvironmentOptions.all.sortedBy { it.name.uppercase(java.util.Locale.ROOT) }) { - DropdownMenuItem(text = { Text(option.name) }, onClick = { - custom = false; onChange(option.name); expanded = false - }) + SettingsRow(stringResource(R.string.game_env_name), null, highlighted = open) { + Box { + ValueChip(if (custom) stringResource(R.string.game_env_custom) else name, open, modifier = Modifier.widthIn(max = 260.dp)) { open = !open } + AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_env_name)) { first -> + for ((i, option) in GameEnvironmentOptions.all.sortedBy { it.name.uppercase(java.util.Locale.ROOT) }.withIndex()) { + MenuItem(option.name, checked = !custom && option.name == name, focusRequester = if (i == 0) first else null) { + custom = false; onChange(option.name); open = false + } + } + MenuItem(stringResource(R.string.game_env_custom), checked = custom) { custom = true; onChange(""); open = false } } } } - if (custom) OutlinedTextField(value = name, onValueChange = onChange, singleLine = true, - label = { Text(stringResource(R.string.game_env_name)) }, modifier = Modifier.fillMaxWidth()) + if (custom) AdbTextField(name, onChange, stringResource(R.string.game_env_name), KeyboardType.Ascii, ImeAction.Next, compact = true) } @Composable private fun VariableValueEditor(name: String, value: String, onChange: (String) -> Unit) { val option = GameEnvironmentOptions.find(name) - var expanded by remember(name) { mutableStateOf(false) } + var open by remember(name) { mutableStateOf(false) } var custom by remember(name) { mutableStateOf(option == null || value !in option.choices && option.type != GameEnvironmentOptions.Type.MULTIPLE) } when { - option?.type == GameEnvironmentOptions.Type.TOGGLE -> Row( - Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - Text(stringResource(R.string.game_env_value)) - Switch(checked = value == option.choices.last(), onCheckedChange = { - onChange(if (it) option.choices.last() else option.choices.first()) - }) + option?.type == GameEnvironmentOptions.Type.TOGGLE -> SettingsRow(stringResource(R.string.game_env_value), null) { + ToggleSwitch(checked = value == option.choices.last()) { onChange(if (it) option.choices.last() else option.choices.first()) } } option != null && option.choices.isNotEmpty() -> { - Box { - OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { - Text(if (custom) stringResource(R.string.game_env_custom_value) else value.ifEmpty { stringResource(R.string.game_env_select_value) }) - } - DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) { - DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom_value)) }, onClick = { custom = true; expanded = false }) - for (choice in option.choices) { - val selected = choice in value.split(',') - DropdownMenuItem(text = { Text(choice) }, leadingIcon = { - if (option.type == GameEnvironmentOptions.Type.MULTIPLE) Checkbox(checked = selected, onCheckedChange = null) - }, onClick = { - custom = false - if (option.type == GameEnvironmentOptions.Type.MULTIPLE) { - onChange(GameEnvironmentOptions.toggle(value, choice)) - } else { - onChange(choice); expanded = false + SettingsRow(stringResource(R.string.game_env_value), null, highlighted = open) { + Box { + ValueChip(if (custom) stringResource(R.string.game_env_custom_value) else value.ifEmpty { stringResource(R.string.game_env_select_value) }, open, modifier = Modifier.widthIn(max = 260.dp)) { open = !open } + AnchoredMenu(open, onDismiss = { open = false }, title = name) { first -> + val multiple = option.type == GameEnvironmentOptions.Type.MULTIPLE + for ((i, choice) in option.choices.withIndex()) { + MenuItem(choice, checked = if (multiple) choice in value.split(',') else !custom && choice == value, focusRequester = if (i == 0) first else null) { + custom = false + if (multiple) onChange(GameEnvironmentOptions.toggle(value, choice)) else { onChange(choice); open = false } } - }) + } + MenuItem(stringResource(R.string.game_env_custom_value), checked = custom) { custom = true; open = false } } } } - if (custom) OutlinedTextField(value = value, onValueChange = onChange, singleLine = true, - label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth()) + if (custom) AdbTextField(value, onChange, stringResource(R.string.game_env_value), KeyboardType.Ascii, ImeAction.Done, compact = true) } - else -> OutlinedTextField(value = value, onValueChange = onChange, singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = if (option?.type == GameEnvironmentOptions.Type.NUMBER) KeyboardType.Number else KeyboardType.Text), - label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth()) + else -> AdbTextField( + value, onChange, stringResource(R.string.game_env_value), + if (option?.type == GameEnvironmentOptions.Type.NUMBER) KeyboardType.Number else KeyboardType.Ascii, ImeAction.Done, compact = true, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ca2eba1f1..507dd6c47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,32 +45,28 @@ Adds a Steam library and scans it for Windows games. Owned games use their Steam entries; the rest become non-Steam shortcuts. Forget removes the shortcuts next session. Files stay on disk. Game environment - Shared defaults and per-game overrides for games using Proton. Applies on the next game launch. - Edit variables + Environment variables for Proton games. + Edit All Proton games - Game profile - Steam app ID or non-Steam compatibility ID - Open profile + Profile + Steam app ID + Open Game %1$s Add variable Variable name Value Save - Cancel - Close Remove - Restore inherited settings + Inherit again Reset this profile Unset for this profile - No variables set. Driver and Proton defaults apply. + No variables set. Inherited Custom variable - D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game. - Could not load or apply the settings. Check available storage and try again. - Use a valid variable name and a value without NUL characters (up to 8192 characters). - The standard Linux Proton runtime does not use this variable; it may require a custom component. + Couldn\'t load or save. Check free storage and try again. + Use a valid name and a value up to 8192 characters. + The standard Proton runtime doesn\'t read this variable. Loading… - Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value. D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection. D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection. Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching. @@ -894,4 +890,13 @@ %1$d of %2$d Off Bottom screen + Applies on the next game launch. A game\'s profile overrides the shared one. + Another game by app ID… + Done + Default + Off + Change… + Change variable + Back to default + Proton games