ui: rebuild the game environment editor in the app's own style

The settings row gets a one-line hint and the same secondary button as every other row. The
editor moves from a stock Material alert, with a paragraph of explanation and a column of text
buttons, into the app's dialog: a profile chip with its menu, the variables in a panel with
each one's value as a chip whose menu changes, removes or restores it, and a footer to add a
variable, reset the profile or finish. Editing a variable uses the same dialog, its name and
value as chips or the app's text fields, and the variable's note in the footer.
This commit is contained in:
xXJSONDeruloXx committed 2026-10-01 23:24:11 -04:00
1 parent 25a5d3fe5d
commit f8feb63dde
3 files changed
+218 -158

No files matched your search

@@ -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<Boolean> {
internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState<Boolean> {
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<Boolean
* like the app's menus, rises above the keyboard, and closes on a tap outside or B.
*/
@Composable
private fun AppDialog(
internal fun AppDialog(
shown: MutableTransitionState<Boolean>, 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) {
@@ -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<GameEnvironment.Config?>(null) }
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
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<String?>(null) }
var menu by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<Pair<String, String>?>(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,
)
}
}
+19 -14
View File
@@ -45,32 +45,28 @@
<string name="second_library_import_hint">Adds a Steam library and scans it for Windows games. Owned games use their Steam entries; the rest become non-Steam shortcuts.</string>
<string name="added_games_forget_hint">Forget removes the shortcuts next session. Files stay on disk.</string>
<string name="game_env_title">Game environment</string>
<string name="game_env_hint">Shared defaults and per-game overrides for games using Proton. Applies on the next game launch.</string>
<string name="game_env_edit">Edit variables</string>
<string name="game_env_hint">Environment variables for Proton games.</string>
<string name="game_env_edit">Edit</string>
<string name="game_env_shared">All Proton games</string>
<string name="game_env_scope">Game profile</string>
<string name="game_env_app_id">Steam app ID or non-Steam compatibility ID</string>
<string name="game_env_open">Open profile</string>
<string name="game_env_scope">Profile</string>
<string name="game_env_app_id">Steam app ID</string>
<string name="game_env_open">Open</string>
<string name="game_env_profile">Game %1$s</string>
<string name="game_env_add">Add variable</string>
<string name="game_env_name">Variable name</string>
<string name="game_env_value">Value</string>
<string name="game_env_save">Save</string>
<string name="game_env_cancel">Cancel</string>
<string name="game_env_close">Close</string>
<string name="game_env_remove">Remove</string>
<string name="game_env_restore">Restore inherited settings</string>
<string name="game_env_restore">Inherit again</string>
<string name="game_env_reset">Reset this profile</string>
<string name="game_env_unset">Unset for this profile</string>
<string name="game_env_empty">No variables set. Driver and Proton defaults apply.</string>
<string name="game_env_empty">No variables set.</string>
<string name="game_env_inherited">Inherited</string>
<string name="game_env_custom">Custom variable</string>
<string name="game_env_defaults">D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game.</string>
<string name="game_env_error">Could not load or apply the settings. Check available storage and try again.</string>
<string name="game_env_invalid">Use a valid variable name and a value without NUL characters (up to 8192 characters).</string>
<string name="game_env_unused">The standard Linux Proton runtime does not use this variable; it may require a custom component.</string>
<string name="game_env_error">Couldn\'t load or save. Check free storage and try again.</string>
<string name="game_env_invalid">Use a valid name and a value up to 8192 characters.</string>
<string name="game_env_unused">The standard Proton runtime doesn\'t read this variable.</string>
<string name="game_env_loading">Loading…</string>
<string name="game_env_overrides">Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value.</string>
<string name="game_env_feature">D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection.</string>
<string name="game_env_shader">D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection.</string>
<string name="game_env_cache">Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching.</string>
@@ -894,4 +890,13 @@
<string name="widgets_n_of_m">%1$d of %2$d</string>
<string name="widgets_off">Off</string>
<string name="screen_bottom">Bottom screen</string>
<string name="game_env_applies">Applies on the next game launch. A game\'s profile overrides the shared one.</string>
<string name="game_env_other">Another game by app ID…</string>
<string name="game_env_done">Done</string>
<string name="game_env_default">Default</string>
<string name="game_env_off">Off</string>
<string name="game_env_change">Change…</string>
<string name="game_env_change_title">Change variable</string>
<string name="game_env_restore_default">Back to default</string>
<string name="game_env_eyebrow">Proton games</string>
</resources>