diff --git a/app/src/main/java/com/steamdeck/launcher/ui/FrameGenDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/FrameGenDialog.kt new file mode 100644 index 000000000..de3e715fb --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/ui/FrameGenDialog.kt @@ -0,0 +1,67 @@ +package com.steamdeck.launcher.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import com.steamdeck.launcher.gpu.FrameGen + +/** + * Off, or an engine at 2x/3x/4x. The LSFG rows are dimmed and unpickable until Lossless Scaling + * is installed in the Steam client — the engine cannot run without its DLL, so offering it would + * only produce a setting that quietly does nothing. + */ +@Composable +fun FrameGenDialog( + engine: String, + multiplier: Int, + lsfgReady: Boolean, + onPick: (engine: String, multiplier: Int) -> Unit, + onDismiss: () -> Unit, +) { + data class Row(val label: String, val engine: String, val multiplier: Int, val enabled: Boolean) + val note = if (lsfgReady) "" else " — install Lossless Scaling in Steam" + val rows = listOf(Row("Off", FrameGen.ENGINE_OFF, 2, true)) + + (2..4).map { Row("Win-FG ${it}×", FrameGen.ENGINE_WINFG, it, true) } + + (2..4).map { Row("LSFG ${it}×$note", FrameGen.ENGINE_LSFG, it, lsfgReady) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Frame generation") }, + text = { + Column { + Text( + "Extra frames are generated between the real ones on the way to the screen, so a game running at 30 fps looks like 60. Takes effect at once, mid-game included.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + for (row in rows) { + val selected = row.engine == engine && (row.engine == FrameGen.ENGINE_OFF || row.multiplier == multiplier) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (row.enabled) 1f else 0.38f) + .clickable(enabled = row.enabled) { onPick(row.engine, row.multiplier) } + .padding(vertical = 2.dp), + ) { + RadioButton(selected = selected, onClick = null, enabled = row.enabled) + Text(row.label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 4.dp)) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + ) +} diff --git a/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt index e19768d2e..4a3820bfd 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt @@ -366,62 +366,62 @@ private fun Rail( if (s.emulators.none { it.installed }) NavItem("install emulators under Desktop & apps", "x", false, small = true, muted = true, register = register, unregister = unregister) { a.onApps() } NavItem("Settings", "settings:lxqt", selected == "settings:lxqt", small = true, tiny = true, muted = true, i = s.emulators.count { it.installed }, register = register, unregister = unregister) { a.onDesktopSettings() } } + Spacer(Modifier.height(6.dp)) + // Setup: the same fold as Steam and Desktop. A row that holds a value opens a small menu + // in place; a row that opens a page lights up while the page is shown. + val menus = remember { MenuHost() } + val item: @Composable (String, String?, Int, String?, () -> Unit) -> Unit = { label, value, i, key, act -> + NavItem(label, key ?: "x", key != null && selected == key, small = true, tiny = true, muted = true, value = value, i = i, register = register, unregister = unregister) { act() } + } + NavItem("Setup", "x", false, caret = openSetup, count = 9, onClick = onToggleSetup) + Sub(openSetup) { + item("Files", null, 0, null, a.onFiles) + item("Desktop & apps", null, 1, null, a.onApps) + item("Compatibility tools", null, 2, null, a.onProtons) + Box { + item("Frame generation", s.frameGenLabel, 3, null) { menus.open = "fg" } + AnchoredMenu( + menus.open == "fg", onDismiss = { if (menus.open == "fg") menus.open = null }, title = "Frame generation", + note = "Extra frames between the real ones on the way to the screen, so 30 fps looks like 60. Takes effect at once, mid-game included.", + ) { + val need = if (s.lsfgReady) null else "install Lossless Scaling in Steam" + MenuItem("Off", checked = s.frameGenEngine == FrameGen.ENGINE_OFF) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); menus.open = null } + for (m in 2..4) MenuItem("Win-FG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_WINFG && s.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); menus.open = null } + for (m in 2..4) MenuItem("LSFG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_LSFG && s.frameGenMultiplier == m, enabled = s.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); menus.open = null } + } + } + item("Performance", null, 4, "performance", a.onPerformance) + item("ROMs folder", s.romsDir?.substringAfterLast('/')?.ifEmpty { s.romsDir } ?: "choose", 5, null, a.onRoms) + Box { + item("Session logs", if (s.logsEnabled) "on" else "off", 6, null) { menus.open = "logs" } + AnchoredMenu( + menus.open == "logs", onDismiss = { if (menus.open == "logs") menus.open = null }, title = "Session logs", + note = "Each session leaves a folder in Downloads: the guest log, the device and network reports, the app's own log.", + ) { + MenuItem("On", checked = s.logsEnabled) { if (!s.logsEnabled) a.onLogs(); menus.open = null } + MenuItem("Off", checked = !s.logsEnabled) { if (s.logsEnabled) a.onLogs(); menus.open = null } + } + } + Box { + item("Start offline", when { s.offlineAccount == null -> "sign in first"; s.offline -> "on"; else -> "off" }, 7, null) { if (s.offlineAccount != null) menus.open = "offline" } + AnchoredMenu( + menus.open == "offline", onDismiss = { if (menus.open == "offline") menus.open = null }, title = "Start offline", + note = "The client starts as ${s.offlineAccount ?: "the saved account"} without the network: the library and installed games, no store.", + ) { + MenuItem("On", checked = s.offline) { if (!s.offline) a.onOffline(); menus.open = null } + MenuItem("Off", checked = !s.offline) { if (s.offline) a.onOffline(); menus.open = null } + } + } + item("Linux runtime", when { + s.busy -> "working…" + !s.ready -> "install" + s.available != null && s.available != s.installed -> "update" + else -> "remove" + }, 8, null, a.onRuntime) + } } } - Spacer(Modifier.height(6.dp)) - // Setup: the same fold as Steam and Desktop. A row that holds a value opens a small menu - // in place; a row that opens a page lights up while the page is shown. - val menus = remember { MenuHost() } - val item: @Composable (String, String?, Int, String?, () -> Unit) -> Unit = { label, value, i, key, act -> - NavItem(label, key ?: "x", key != null && selected == key, small = true, tiny = true, muted = true, value = value, i = i, register = register, unregister = unregister) { act() } - } - NavItem("Setup", "x", false, caret = openSetup, count = 9, onClick = onToggleSetup) - Sub(openSetup) { - item("Files", null, 0, null, a.onFiles) - item("Desktop & apps", null, 1, null, a.onApps) - item("Compatibility tools", null, 2, null, a.onProtons) - Box { - item("Frame generation", s.frameGenLabel, 3, null) { menus.open = "fg" } - AnchoredMenu( - menus.open == "fg", onDismiss = { if (menus.open == "fg") menus.open = null }, title = "Frame generation", - note = "Extra frames between the real ones on the way to the screen, so 30 fps looks like 60. Takes effect at once, mid-game included.", - ) { - val need = if (s.lsfgReady) null else "install Lossless Scaling in Steam" - MenuItem("Off", checked = s.frameGenEngine == FrameGen.ENGINE_OFF) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); menus.open = null } - for (m in 2..4) MenuItem("Win-FG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_WINFG && s.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); menus.open = null } - for (m in 2..4) MenuItem("LSFG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_LSFG && s.frameGenMultiplier == m, enabled = s.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); menus.open = null } - } - } - item("Performance", null, 4, "performance", a.onPerformance) - item("ROMs folder", s.romsDir?.substringAfterLast('/')?.ifEmpty { s.romsDir } ?: "choose", 5, null, a.onRoms) - Box { - item("Session logs", if (s.logsEnabled) "on" else "off", 6, null) { menus.open = "logs" } - AnchoredMenu( - menus.open == "logs", onDismiss = { if (menus.open == "logs") menus.open = null }, title = "Session logs", - note = "Each session leaves a folder in Downloads: the guest log, the device and network reports, the app's own log.", - ) { - MenuItem("On", checked = s.logsEnabled) { if (!s.logsEnabled) a.onLogs(); menus.open = null } - MenuItem("Off", checked = !s.logsEnabled) { if (s.logsEnabled) a.onLogs(); menus.open = null } - } - } - Box { - item("Start offline", when { s.offlineAccount == null -> "sign in first"; s.offline -> "on"; else -> "off" }, 7, null) { if (s.offlineAccount != null) menus.open = "offline" } - AnchoredMenu( - menus.open == "offline", onDismiss = { if (menus.open == "offline") menus.open = null }, title = "Start offline", - note = "The client starts as ${s.offlineAccount ?: "the saved account"} without the network: the library and installed games, no store.", - ) { - MenuItem("On", checked = s.offline) { if (!s.offline) a.onOffline(); menus.open = null } - MenuItem("Off", checked = !s.offline) { if (s.offline) a.onOffline(); menus.open = null } - } - } - item("Linux runtime", when { - s.busy -> "working…" - !s.ready -> "install" - s.available != null && s.available != s.installed -> "update" - else -> "remove" - }, 8, null, a.onRuntime) - } Text( "credits", fontSize = 11.sp, color = colors.onSurfaceVariant, modifier = Modifier.clip(Shape10).clickable(onClick = a.onCredits).padding(horizontal = 10.dp, vertical = 8.dp),