Compare commits

...
24 changed files with 1535 additions and 571 deletions

No files matched your search

@@ -14,6 +14,7 @@ import com.droiddeck.launcher.session.ProtonDefault
*/
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
var defaultProtonId by mutableStateOf<String?>(null)
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
var compCatalogAt by mutableStateOf(0L)
var compProton by mutableStateOf<String?>(null)
@@ -21,7 +22,6 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
var compChecking by mutableStateOf(false)
var compBusy by mutableStateOf<String?>(null)
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
private var shownDefault: String? = null
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
fun refreshComponents(snapshotFirst: Boolean = false) {
@@ -33,11 +33,9 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() }
ui.post {
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
defaultProtonId = chosen
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (chosen != null && chosen != shownDefault) {
shownDefault = chosen
compProton = chosen
} else if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) {
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) {
compProton = chosen ?: snap?.protons?.firstOrNull()?.proton?.id
}
}
@@ -46,7 +44,6 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
fun chooseProton(id: String) {
val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return
compProton = id
Thread({
val message = runCatching {
when (ProtonDefault.request(activity, proton)) {
@@ -55,7 +52,10 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
ProtonDefault.Outcome.NOT_RUNNABLE -> activity.getString(R.string.comp_default_not_runnable, proton.name)
}
}.getOrElse { e -> activity.getString(R.string.comp_default_failed, e.message ?: e.javaClass.simpleName) }
ui.post { android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show() }
ui.post {
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "components-default").start()
}
@@ -51,7 +51,6 @@ import com.droiddeck.launcher.core.WifiDiscovery
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage
import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.ConfirmDialog
@@ -60,6 +59,12 @@ import com.droiddeck.launcher.ui.ControllerMappingPage
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.ControllerEditorActivity
import com.droiddeck.launcher.ui.FrontEndScreen
import com.droiddeck.launcher.ui.SettingsCategory
import com.droiddeck.launcher.ui.SettingsDestination
import com.droiddeck.launcher.ui.SettingsHistory
import com.droiddeck.launcher.ui.SettingsCategoryPage
import com.droiddeck.launcher.ui.ModeSettingsContent
import com.droiddeck.launcher.ui.MenuHost
import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
import com.droiddeck.launcher.frontend.CoverArt
@@ -112,11 +117,11 @@ class MainActivity : ComponentActivity() {
private var steamDeckMode by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showProtons by mutableStateOf(false)
private val showProtons get() = settingsHistory.current is SettingsDestination.Protons
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private val showComponents get() = settingsHistory.current is SettingsDestination.Components
private var focusComponentsContent by mutableStateOf(true)
private var showMapping by mutableStateOf(false)
private val showMapping get() = settingsHistory.current is SettingsDestination.Mapping
private var saveBusy: String? = null
/** What to do with the zip or folder the file picker hands back after a game page's Manage saves. */
private var onSavePicked: ((File) -> Unit)? = null
@@ -130,7 +135,7 @@ class MainActivity : ComponentActivity() {
private var desktopInstalled by mutableStateOf(false)
private var offlineAccount by mutableStateOf<String?>(null)
private var offline by mutableStateOf(false)
private var showPerformance by mutableStateOf(false)
private val showPerformance get() = settingsHistory.current is SettingsDestination.Performance
private var clientOverride by mutableStateOf(false)
private var clientCores by mutableStateOf<Set<Int>>(emptySet())
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
@@ -303,7 +308,9 @@ class MainActivity : ComponentActivity() {
}
}
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var settingsHistory by mutableStateOf(SettingsHistory())
private val settingsMode get() = (settingsHistory.current as? SettingsDestination.Category)?.mode
private var requestedRailTab by mutableStateOf<String?>(null)
private var resolutionCap by mutableStateOf(1080)
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var fexPreset by mutableStateOf("")
@@ -493,7 +500,7 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
sm != null -> null
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showComponents -> { { ComponentsHost() } }
@@ -514,7 +521,13 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
pageKey = settingsHistory.current?.key,
settingsCategory = (settingsHistory.current as? SettingsDestination.Category)?.category,
settingsMode = sm ?: SessionService.MODE_STEAM,
settingsTarget = (settingsHistory.current as? SettingsDestination.Category)?.target
?: (settingsHistory.current as? SettingsDestination.Performance)?.target
?: (settingsHistory.current as? SettingsDestination.Components)?.target,
requestedRailTab = requestedRailTab,
theme = theme,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
@@ -609,7 +622,7 @@ class MainActivity : ComponentActivity() {
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onPerformance = { refreshCores(); settingsHistory = settingsHistory.open(SettingsDestination.Performance()) },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onBrowseFiles = { dir ->
@@ -643,7 +656,18 @@ class MainActivity : ComponentActivity() {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onPageBack = { settingsBack() },
onCloseSettings = { settingsHistory = SettingsHistory() },
onSettingsCategory = { category, target -> openSettingsCategory(category, target) },
onSettingsMode = { mode ->
val destination = settingsHistory.current as? SettingsDestination.Category
if (destination != null) {
refreshModeSettings(mode)
settingsHistory = settingsHistory.replace(destination.copy(mode = mode, target = null))
}
},
onOpenUpdates = { settingsHistory = SettingsHistory(); requestedRailTab = "updates" },
onRailTabHandled = { requestedRailTab = null },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
@@ -730,11 +754,18 @@ class MainActivity : ComponentActivity() {
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onMapping = { settingsHistory = settingsHistory.open(SettingsDestination.Mapping) },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
page = page,
settingsContent = { s, a, openDeveloperOptions, requestWirelessAdb ->
s.settingsCategory?.let { category ->
SettingsCategoryPage(s, a, category, s.settingsMode, s.settingsTarget, openDeveloperOptions, requestWirelessAdb) { host ->
ModeSettingsHost(s.settingsMode, category, host)
}
}
},
)
}
if (showRoms) RomsDialog(
@@ -969,11 +1000,7 @@ class MainActivity : ComponentActivity() {
}
private fun openProtons() {
settingsMode = null
showPerformance = false
showComponents = false
showMapping = false
showProtons = true
settingsHistory = settingsHistory.open(SettingsDestination.Protons)
protons.refreshProtons()
}
@@ -988,18 +1015,14 @@ class MainActivity : ComponentActivity() {
mapping = settings.mapping,
onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() },
onReset = { ControllerPrefs.resetMapping(this); refreshController() },
onBack = { showMapping = false },
onBack = { settingsBack() },
)
}
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
private fun openComponents(focusContent: Boolean = true, tab: String? = null, target: String? = null) {
focusComponentsContent = focusContent
if (tab != null) components.compComp = tab
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
settingsHistory = settingsHistory.open(SettingsDestination.Components(tab ?: components.compComp, target))
components.refreshComponents(snapshotFirst = true)
drivers.refreshDrivers()
}
@@ -1025,8 +1048,15 @@ class MainActivity : ComponentActivity() {
busy = if (busy) stage else components.compBusy,
downloads = components.compDownloads,
requestInitialFocus = focusComponentsContent,
onProton = { components.chooseProton(it) },
onComp = { components.compComp = it },
onProton = { components.compProton = it },
defaultProtonId = components.defaultProtonId,
onDefaultProton = { components.chooseProton(it) },
onComp = { tab ->
components.compComp = tab
if (settingsHistory.current is SettingsDestination.Components) {
settingsHistory = settingsHistory.replace(SettingsDestination.Components(tab))
}
},
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
onCancelQueued = { components.compProton?.let { pid -> components.componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, components.compComp); "The waiting swap was cancelled." } } },
@@ -1035,7 +1065,7 @@ class MainActivity : ComponentActivity() {
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onBack = { showComponents = false },
onBack = { settingsBack() },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
@@ -1067,7 +1097,7 @@ class MainActivity : ComponentActivity() {
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
onRemove = { id -> protons.removeProton(id) },
onBack = { showProtons = false },
onBack = { settingsBack() },
)
}
@@ -1114,8 +1144,8 @@ class MainActivity : ComponentActivity() {
}
@Composable
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
private fun ModeSettingsHost(mode: String, category: SettingsCategory, host: MenuHost) {
ModeSettingsContent(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
@@ -1248,8 +1278,13 @@ class MainActivity : ComponentActivity() {
onPickDeckyPluginZip = {
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Decky plugin ZIP"))
},
onDismiss = { settingsMode = null },
onDismiss = { settingsBack() },
),
category = category,
host = host,
)
if (category == SettingsCategory.DISPLAY) com.droiddeck.launcher.ui.GraphicsSettingsContent(
host, upscaler, { value -> SessionPrefs.setUpscaler(this, value); upscaler = value },
)
}
@@ -1278,7 +1313,7 @@ class MainActivity : ComponentActivity() {
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onProotFastPath = { on -> SessionPrefs.setProotFastPath(this, on); prootFastPath = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name); guestHostname = SessionPrefs.guestHostname(this) },
onClientCore = { core, on ->
clientCores = if (on) clientCores + core else clientCores - core
SessionPrefs.setClientCpus(this, CpuCores.format(clientCores))
@@ -1287,7 +1322,7 @@ class MainActivity : ComponentActivity() {
gameCores = if (on) gameCores + core else gameCores - core
SessionPrefs.setGameCpus(this, CpuCores.format(gameCores))
},
onDismiss = { showPerformance = false },
onDismiss = { settingsBack() },
)
}
@@ -1324,11 +1359,44 @@ class MainActivity : ComponentActivity() {
private var pipAutoEnter by mutableStateOf(false)
private fun openModeSettings(mode: String) {
refreshModeSettings(mode)
settingsHistory = settingsHistory.open(SettingsDestination.Category(SettingsCategory.SESSIONS, mode))
}
private fun openSettingsCategory(category: SettingsCategory, target: String?) {
val entry = com.droiddeck.launcher.ui.SettingsCatalog.entries.firstOrNull { it.target == target }
val mode = entry?.mode ?: SessionService.MODE_STEAM
refreshModeSettings(mode)
val categoryTarget = com.droiddeck.launcher.ui.settingsCategoryTarget(target)
settingsHistory = settingsHistory.open(SettingsDestination.Category(category, mode, categoryTarget))
when {
target == "components" -> openComponents(true, "fex")
target == "default-proton" || target == "editing-proton" -> openComponents(true, "fex", target)
target == "gpu-drivers" -> openComponents(true, com.droiddeck.launcher.ui.GPU_TAB)
target == "protons" -> openProtons()
target == "controller-mapping" -> settingsHistory = settingsHistory.open(SettingsDestination.Mapping)
target == "performance" || target?.startsWith("performance:") == true -> {
refreshCores()
settingsHistory = settingsHistory.open(SettingsDestination.Performance(target?.substringAfter("performance:", "")?.ifEmpty { null }))
}
}
}
private fun settingsBack() {
settingsHistory = settingsHistory.back()
when (val destination = settingsHistory.current) {
is SettingsDestination.Category -> refreshModeSettings(destination.mode)
is SettingsDestination.Components -> {
destination.tab?.let { components.compComp = it }
components.refreshComponents(snapshotFirst = true)
drivers.refreshDrivers()
}
else -> Unit
}
}
private fun refreshModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
@@ -1360,7 +1428,6 @@ class MainActivity : ComponentActivity() {
renderer = SessionPrefs.desktopRenderer(this)
gameStorage = SessionPrefs.gameStorage(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
settingsMode = mode
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it animates in.
Thread({
@@ -108,6 +108,8 @@ fun ComponentsPage(
/** The GPU drivers tab ([GPU_TAB]): what it shows and does. */
gpu: GpuDriversState = GpuDriversState(),
gpuActions: GpuDriversActions = GpuDriversActions(),
defaultProtonId: String? = null,
onDefaultProton: (String) -> Unit = {},
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -117,11 +119,13 @@ fun ComponentsPage(
var confirmTitle by remember { mutableStateOf("") }
var about by remember { mutableStateOf(false) }
var protonMenu by remember { mutableStateOf(false) }
var defaultProtonMenu by remember { mutableStateOf(false) }
var confirmVerb by remember { mutableStateOf(R.string.comp_swap) }
val ctx = LocalContext.current
fun ask(title: String, body: String, verb: Int, action: () -> Unit) { confirmTitle = title; confirmVerb = verb; confirm = body to action }
// The GPU drivers tab's Advanced pages: one driver list on its own, full page.
var driverPage by remember { mutableStateOf<String?>(null) }
val gpuScroll = rememberScrollState()
val gpuTab = comp == GPU_TAB
when (driverPage) {
"rt" -> {
@@ -172,34 +176,54 @@ fun ComponentsPage(
}
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
// ---- which Proton, which component ------------------------------------------------------
// ---- Steam default Proton and the component editing target -----------------------------
val comps = ComponentsManager.COMPONENTS
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
if (view != null && !gpuTab) Box {
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = stringResource(R.string.comp_proton),
note = stringResource(R.string.comp_proton_note)) { first ->
views.forEachIndexed { i, v ->
val swaps = v.components.values.count { it.activeFile != null }
MenuItem(
v.proton.name, checked = v.proton.id == view.proton.id,
detail = (if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version))
.let {
when (v.sync?.state) {
"pack" -> stringResource(R.string.comp_esync_pack, it)
"wanted" -> stringResource(R.string.comp_esync_wanted, it)
"builtin" -> stringResource(R.string.comp_esync_builtin, it)
else -> it
}
}
.let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it },
focusRequester = if (i == 0) first else null,
) { onProton(v.proton.id); protonMenu = false }
if (view != null && !gpuTab) {
SettingsAnchor("default-proton", Modifier.widthIn(max = 300.dp)) { Column {
Text("Default Proton", fontSize = 12.sp, color = colors.onSurfaceVariant)
Box {
val defaultName = views.firstOrNull { it.proton.id == defaultProtonId }?.proton?.name ?: "Choose default"
ValueChip(defaultName, defaultProtonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { defaultProtonMenu = !defaultProtonMenu }
AnchoredMenu(defaultProtonMenu, onDismiss = { defaultProtonMenu = false }, title = "Default Proton",
note = stringResource(R.string.comp_proton_note)) { first ->
views.forEachIndexed { i, v ->
MenuItem(v.proton.name, checked = v.proton.id == defaultProtonId,
focusRequester = if (i == 0) first else null,
) { onDefaultProton(v.proton.id); defaultProtonMenu = false }
}
}
}
}
} }
SettingsAnchor("editing-proton", Modifier.widthIn(max = 300.dp)) { Column {
Text("Editing components for", fontSize = 12.sp, color = colors.onSurfaceVariant)
Box {
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Editing components for",
note = "Component changes apply to this Proton. The default Proton is selected separately above.") { first ->
views.forEachIndexed { i, v ->
val swaps = v.components.values.count { it.activeFile != null }
MenuItem(
v.proton.name, checked = v.proton.id == view.proton.id,
detail = (if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version))
.let {
when (v.sync?.state) {
"pack" -> stringResource(R.string.comp_esync_pack, it)
"wanted" -> stringResource(R.string.comp_esync_wanted, it)
"builtin" -> stringResource(R.string.comp_esync_builtin, it)
else -> it
}
}
.let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it },
focusRequester = if (i == 0) first else null,
) { onProton(v.proton.id); protonMenu = false }
}
}
}
} }
}
val tabs = listOf(GPU_TAB) + comps
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: stringResource(R.string.comp_gpu_drivers) }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
@@ -230,7 +254,7 @@ fun ComponentsPage(
// ---- the lists ------------------------------------------------------------------------------
when {
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(gpuScroll).padding(bottom = 16.dp)) {
GpuDriversPanel(gpu, gpuActions) { driverPage = it }
Row(modifier = Modifier.padding(top = 14.dp)) {
SmallButton(stringResource(R.string.comp_import_zip), onClick = gpuActions.onImportZip)
@@ -42,12 +42,12 @@ private fun Swatch(color: Int) {
@Composable
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
ChoiceRow(
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
host, "controller-osc", stringResource(R.string.mode_osc), null,
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
val tintOpen = host.open == "controller-tint"
SettingsRow(stringResource(R.string.ctrl_color), stringResource(R.string.ctrl_color_hint), highlighted = tintOpen) {
SettingsAnchor("controller-tint") { SettingsRow(stringResource(R.string.ctrl_color), null, highlighted = tintOpen) {
Box {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: stringResource(R.string.ctrl_custom), tintOpen) {
host.open = if (tintOpen) null else "controller-tint"
@@ -61,24 +61,24 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
}
}
}
}
} }
ChoiceRow(host, "controller-opacity", stringResource(R.string.ctrl_opacity), null, ControllerPrefs.opacities.map { it to stringResource(R.string.ctrl_percent, it) }, c.opacity, onPick = a.onOpacity)
ChoiceRow(host, "controller-size", stringResource(R.string.ctrl_size), stringResource(R.string.ctrl_size_hint), ControllerPrefs.sizes.map { it to stringResource(R.string.ctrl_percent, it) }, c.size, onPick = a.onSize)
ChoiceRow(host, "controller-size", stringResource(R.string.ctrl_size), null, ControllerPrefs.sizes.map { it to stringResource(R.string.ctrl_percent, it) }, c.size, onPick = a.onSize)
ToggleRow(host, "controller-stick-click", stringResource(R.string.ctrl_stick_click), stringResource(R.string.ctrl_stick_click_hint), c.stickClick, onChange = a.onStickClick)
ToggleRow(host, "controller-adaptive", stringResource(R.string.ctrl_adaptive), stringResource(R.string.ctrl_adaptive_hint), c.adaptiveSticks, onChange = a.onAdaptiveSticks)
ToggleRow(host, "controller-rumble", stringResource(R.string.ctrl_rumble), null, c.rumble, onChange = a.onRumble)
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
SettingsAnchor("controller-layout") { SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
SecondaryButton(stringResource(R.string.ctrl_reset), enabled = c.customLayout) { a.onResetLayout() }
}
}
} }
val remapped = c.mapping.count { (id, target) -> id != target }
ActionRow(stringResource(R.string.ctrl_mapping), if (remapped == 0) stringResource(R.string.ctrl_mapping_default) else stringResource(R.string.ctrl_mapping_count, remapped, c.mapping.size), stringResource(R.string.ctrl_configure), a.onMapping)
ActionRow(stringResource(R.string.ctrl_reset_all), stringResource(R.string.ctrl_reset_all_hint), stringResource(R.string.ctrl_reset), a.onResetAll)
SettingsAnchor("controller-mapping") { ActionRow(stringResource(R.string.ctrl_mapping), if (remapped == 0) stringResource(R.string.ctrl_mapping_default) else stringResource(R.string.ctrl_mapping_count, remapped, c.mapping.size), stringResource(R.string.ctrl_configure), a.onMapping) }
SettingsAnchor("controller-reset") { ActionRow(stringResource(R.string.ctrl_reset_all), stringResource(R.string.ctrl_reset_all_hint), stringResource(R.string.ctrl_reset), a.onResetAll) }
}
@Composable
@@ -119,6 +119,7 @@ internal fun Pane(
val pal = LocalPalette.current
// Pages keep drawing while they leave, so one can fold away instead of blinking out.
val pages = remember { HashMap<String, @Composable () -> Unit>() }
val savedPages = androidx.compose.runtime.saveable.rememberSaveableStateHolder()
// Pages opened from a control (the cog), with where that control sat, in this pane's coordinates.
val origins = remember { HashMap<String, Origin>() }
val paneAt = remember { arrayOf(Offset.Zero) }
@@ -154,6 +155,7 @@ internal fun Pane(
label = "pane",
) { key ->
val shown = if (page != null && key == s.pageKey) page else pages[key]
savedPages.SaveableStateProvider(key) {
if (shown != null) {
DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } }
val from = origins[key]
@@ -168,6 +170,7 @@ internal fun Pane(
}
}
else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
}
}
}
}
@@ -209,10 +212,10 @@ private fun Content(
val narrow = LocalNarrowPane.current
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
// Setup scrolls inside itself, under its tabs.
if (selected == "setup") {
// Settings has its own search and scrolling.
if (selected == "settings") {
Column(modifier = modifier.padding(horizontal = padH, vertical = padV)) {
SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb)
SettingsHub(s, a)
}
return
}
@@ -93,14 +93,14 @@ internal fun SideRail(
val compact = LocalConfiguration.current.screenHeightDp < 420
// Under 600dp wide (4:3 and square screens) the labels would cost a fifth of the width.
val iconOnly = isNarrowScreen()
val setupNeedsAttention = !s.ready || (s.available != null && s.available != s.installed) ||
val settingsNeedsAttention = !s.ready || (s.available != null && s.available != s.installed) ||
PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
// Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
val count = 5 + (if (s.storeEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
Column(
@@ -113,10 +113,9 @@ internal fun SideRail(
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
RailItem(stringResource(R.string.settings_title), Icons.Outlined.Tune, "settings", selected == "settings", compact, iconOnly, fit, badge = settingsNeedsAttention, onFocus = { onFocusSelect("settings") }) { onSelect("settings") }
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
}
}
@@ -100,6 +100,10 @@ class FrontEndState(
val shortcutLibraryScanning: Boolean = false,
val gameSyncFolder: String? = null,
val pageKey: String? = null,
val settingsCategory: SettingsCategory? = null,
val settingsMode: String = "steam",
val settingsTarget: String? = null,
val requestedRailTab: String? = null,
val theme: String = Themes.GRAPHITE,
val isHomeApp: Boolean = false,
val homeScreenEnabled: Boolean = false,
@@ -122,7 +126,7 @@ class FrontEndState(
val showPhantomGate: Boolean = false,
val launcherFullscreen: Boolean = true,
val animationsEnabled: Boolean = true,
/** The Flathub Store, a beta the user turns on in Setup. */
/** The Flathub Store, a beta the user turns on in Settings. */
val storeEnabled: Boolean = false,
/** The Updates page: DroidDeck's own builds and the channel followed. */
val updates: UpdatesState = UpdatesState(),
@@ -167,6 +171,11 @@ class FrontEndActions(
val onClearLogs: () -> Unit = {},
val onOffline: () -> Unit,
val onPageBack: () -> Unit = {},
val onCloseSettings: () -> Unit = {},
val onSettingsCategory: (SettingsCategory, String?) -> Unit = { _, _ -> },
val onSettingsMode: (String) -> Unit = {},
val onOpenUpdates: () -> Unit = {},
val onRailTabHandled: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
@@ -338,14 +347,22 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR
}
@Composable
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) {
fun FrontEndScreen(
s: FrontEndState,
a: FrontEndActions,
page: (@Composable () -> Unit)? = null,
settingsContent: (@Composable (FrontEndState, FrontEndActions, () -> Unit, (Boolean) -> Unit) -> Unit)? = null,
) {
val frontFocus = remember { FrontFocus() }
CompositionLocalProvider(LocalFrontFocus provides frontFocus) { FrontEndScreenBody(s, a, page, frontFocus) }
CompositionLocalProvider(LocalFrontFocus provides frontFocus) { FrontEndScreenBody(s, a, page, frontFocus, settingsContent) }
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) {
private fun FrontEndScreenBody(
s: FrontEndState, a: FrontEndActions, externalPage: (@Composable () -> Unit)?, frontFocus: FrontFocus,
settingsContent: (@Composable (FrontEndState, FrontEndActions, () -> Unit, (Boolean) -> Unit) -> Unit)?,
) {
var selected by rememberSaveable { mutableStateOf(if (s.shortcutPicker) "games" else "steam") }
LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" }
var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) }
@@ -365,6 +382,14 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
wirelessAdbDesiredEnabled = enabled
showWirelessAdbFix = true
}
val page: (@Composable () -> Unit)? = if (s.settingsCategory != null && settingsContent != null) {
{ settingsContent(s, a, requestDeveloperOptions, requestWirelessAdbFix) }
} else externalPage?.let { content ->
{ CompositionLocalProvider(LocalSettingsTarget provides s.settingsTarget, LocalSettingsAnchorsEnabled provides true) { content() } }
}
LaunchedEffect(s.requestedRailTab) {
s.requestedRailTab?.let { selected = it; a.onRailTabHandled() }
}
LaunchedEffect(s.showPhantomGate, s.phantomProcessStatus) {
if (phantomGateVisible) {
while (true) {
@@ -383,7 +408,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
// The Store turned off in Setup takes its page with it.
// The Store turned off in Settings takes its page with it.
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
// The last game uninstalled leaves the Games tab on its empty state.
LaunchedEffect(s.steamGames.isEmpty()) {
@@ -392,23 +417,15 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
remember { Motion.refresh(ctx); true }
val railSelection = when {
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
s.pageKey?.startsWith("settings:steam") == true -> "steam"
s.pageKey?.startsWith("settings:") == true -> "desktop"
s.pageKey != null -> "settings"
selected.startsWith("app:") -> "games"
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
else -> s.pageKey ?: selected
else -> selected
}
// Bumped each time a rail item is picked, so a controller moves on into the new page.
var railPicks by remember { mutableStateOf(0) }
val showRailPage: (String, Boolean) -> Unit = { key, focusContent ->
// Components is a full page like Protons or Performance, opened over the current rail
// selection rather than replacing it.
if (key == "components") a.onComponents(focusContent)
else {
if (s.pageKey != null) a.onPageBack()
selected = key
}
val showRailPage: (String, Boolean) -> Unit = { key, _ ->
if (s.pageKey != null) a.onCloseSettings()
selected = key
}
val onRailFocus: (String) -> Unit = { key ->
if (key != railSelection) showRailPage(key, false)
@@ -77,7 +77,7 @@ import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.R
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
// Existing launcher preference owners, grouped into Settings categories.
private enum class CheckState { OK, WARN, BUSY }
@@ -122,30 +122,134 @@ private fun CheckRow(state: CheckState, title: String, detail: String?, divider:
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** Settings owned by Setup's existing state/actions, split into hub categories. */
@Composable
internal fun SetupPanel(
internal fun SetupContent(
s: FrontEndState,
a: FrontEndActions,
category: SettingsCategory,
host: MenuHost,
onOpenDeveloperOptions: () -> Unit,
onRequestWirelessAdb: (Boolean) -> Unit,
) {
when (category) {
SettingsCategory.GENERAL -> GeneralSettings(s, a, host)
SettingsCategory.DISPLAY -> DisplaySettings(s, a, host)
SettingsCategory.CONTROLS -> ControlsSettings(s, a, host)
SettingsCategory.AUDIO -> Unit
SettingsCategory.LIBRARY -> LibrarySettings(s, a)
SettingsCategory.SESSIONS -> SessionSettings(s, a, host)
SettingsCategory.COMPATIBILITY -> CompatibilitySettings(s, a)
SettingsCategory.SUPPORT -> SupportSettings(s, a, onOpenDeveloperOptions, onRequestWirelessAdb)
}
}
@Composable
private fun GeneralSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
SettingsGroup(stringResource(R.string.setup_launcher)) {
SettingsAnchor("theme") { SettingsRow(stringResource(R.string.setup_theme), null) {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
} }
ToggleRow(host, "home-screen", stringResource(R.string.setup_home),
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
s.homeScreenEnabled) { a.onHomeScreen(it) }
ToggleRow(host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
s.launcherFullscreen) { a.onLauncherFullscreen(it) }
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
s.animationsEnabled, onChange = a.onAnimationsEnabled)
SettingsAnchor("default-home") {
if (s.homeScreenEnabled) ActionRow(stringResource(R.string.setup_default_home),
s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
else SettingsRow(stringResource(R.string.setup_default_home), "Enable Home screen above to choose DroidDeck as the default Home app.") {}
}
}
SettingsGroup(stringResource(R.string.setup_linux_apps)) {
ToggleRow(host, "store-enabled", stringResource(R.string.setup_store),
if (s.storeEnabled) stringResource(R.string.setup_store_on) else stringResource(R.string.setup_store_off),
s.storeEnabled, onChange = a.onStoreEnabled)
}
}
@Composable
private fun DisplaySettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
SettingsGroup("Display and graphics") {
SettingsAnchor("fg") { SettingsRow(stringResource(R.string.frame_gen_title), null) {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(s, a, host)
}
} }
}
}
@Composable
private fun ControlsSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
val controller = s.controller
if (controller != null && a.controller != null) SettingsAnchor("controller-actions") { SettingsGroup(stringResource(R.string.setup_controller)) {
ControllerRows(host, s.oscMode, controller, a.controller)
}
} else Note(stringResource(R.string.setup_controller_unavailable))
SettingsGroup(stringResource(R.string.setup_session)) {
ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
s.backActionsInverted, onPick = a.onBackActionsInverted)
}
}
@Composable
private fun LibrarySettings(s: FrontEndState, a: FrontEndActions) {
SettingsGroup("Library and files") {
val roms = s.romsDir ?: stringResource(R.string.setup_tool_roms_hint)
SettingsAnchor("files") { ActionRow(stringResource(R.string.setup_tool_files), null, "Open", a.onFiles) }
SettingsAnchor("roms") { ActionRow(stringResource(R.string.setup_tool_roms), roms, "Open", a.onRoms) }
}
}
@Composable
private fun SessionSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
SettingsGroup(stringResource(R.string.setup_session)) {
ToggleRow(host, "offline", stringResource(R.string.setup_offline),
s.offlineAccount?.let { stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offline, enabled = s.offlineAccount != null, onChange = { a.onOffline() })
}
}
@Composable
private fun CompatibilitySettings(s: FrontEndState, a: FrontEndActions) {
SettingsGroup("Compatibility tools") {
SettingsAnchor("components") { ActionRow(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "Open") { a.onComponents(true) } }
SettingsAnchor("protons") { ActionRow(stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), "Open", a.onProtons) }
SettingsAnchor("performance") { ActionRow(stringResource(R.string.setup_tool_performance), null, "Open", a.onPerformance) }
}
}
@Composable
private fun SupportSettings(
s: FrontEndState,
a: FrontEndActions,
onOpenDeveloperOptions: () -> Unit,
onRequestWirelessAdb: (Boolean) -> Unit,
) {
val context = LocalContext.current
val host = rememberMenuHost()
val ctx = LocalContext.current
var processLimitBusy by remember { mutableStateOf(false) }
var processLimitMessage by remember { mutableStateOf<String?>(null) }
val setProcessLimit: (Boolean) -> Unit = { enabled ->
processLimitBusy = true
processLimitMessage = null
a.onSetPhantomProcessLimit(enabled) { error ->
processLimitBusy = false
if (error == null) {
a.onRefreshPhantomStatus()
} else {
processLimitMessage = ctx.getString(R.string.setup_limit_adb_failed)
onRequestWirelessAdb(enabled)
}
}
}
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
val gpuName = remember { DeviceSupport.gpuName() }
val runtime = when {
s.busy -> stringResource(R.string.common_working)
s.removalPending -> stringResource(R.string.setup_retry_removal)
@@ -153,231 +257,106 @@ internal fun SetupPanel(
s.available != null && s.available != s.installed -> stringResource(R.string.common_update)
else -> stringResource(R.string.setup_manage)
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
val gpuName = remember { DeviceSupport.gpuName() }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
val checks = 4
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
// and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
var tab by rememberSaveable { mutableStateOf(0) }
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
val inputModeManager = LocalInputModeManager.current
// The control a controller was on went with the old tab: it lands on the new tab itself.
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
runCatching { tabFocus[tab].requestFocus() }
val setProcessLimit: (Boolean) -> Unit = { enabled ->
processLimitBusy = true
processLimitMessage = null
a.onSetPhantomProcessLimit(enabled) { error ->
processLimitBusy = false
if (error == null) a.onRefreshPhantomStatus()
else {
processLimitMessage = context.getString(R.string.setup_limit_adb_failed)
onRequestWirelessAdb(enabled)
}
}
}
Rise(0, Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().bumpers(
onPrevious = { pick((tab + tabs.size - 1) % tabs.size) },
onNext = { pick((tab + 1) % tabs.size) },
),
) {
PageHeader(stringResource(R.string.setup_title)) {
Chip(if (readyCount == checks) stringResource(R.string.setup_all_set) else stringResource(R.string.setup_n_ready, readyCount, checks), ok = readyCount == checks)
}
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
when (tab) {
0 -> {
SectionTitle(stringResource(R.string.setup_system_check), null)
// What Steam needs, one row each: green when done, one button when not. The
// process-limit controls only open under their row.
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
CheckRow(
if (gpuOk) CheckState.OK else CheckState.WARN,
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_supported)
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested)
else -> stringResource(R.string.setup_gpu_unsupported)
},
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.name, gpuName)
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.name, gpu.supportText.replaceFirstChar { it.lowercase() })
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
},
)
CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
stringResource(R.string.setup_runtime),
when {
s.busy -> null
s.removalPending -> stringResource(R.string.runtime_removal_incomplete)
!s.ready -> stringResource(R.string.setup_runtime_missing)
s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed))
else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed))
},
divider = !s.busy,
) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) }
if (s.busy) {
Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
Text(
if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage,
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
)
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
stringResource(R.string.setup_limit),
when (s.phantomProcessStatus) {
PhantomProcessStatus.ENABLED -> stringResource(R.string.setup_limit_on)
PhantomProcessStatus.UNSET -> stringResource(R.string.setup_limit_unset)
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
},
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
SecondaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_details), compact = true) { showLimitDetails = !showLimitDetails }
}
}
AnimatedVisibility(showLimitDetails, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
// One sentence and at most three buttons. The computer route and its
// raw command live on the full page Wireless debugging opens.
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
Text(
if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)
else PhantomProcessLimit.instructions(s.phantomProcessStatus),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
Actions {
if (limitBlocks) {
PrimaryButton(stringResource(R.string.setup_dev_options), compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton(stringResource(R.string.setup_use_wireless), compact = true, enabled = !processLimitBusy) { setProcessLimit(false) }
SecondaryButton(stringResource(R.string.setup_check_again), compact = true, onClick = a.onRefreshPhantomStatus)
} else if (s.phantomProcessStatus == PhantomProcessStatus.DISABLED) {
SecondaryButton(stringResource(R.string.setup_limit_turn_on), compact = true, enabled = !processLimitBusy) { setProcessLimit(true) }
}
}
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
}
CheckRow(
if (signedIn) CheckState.OK else CheckState.WARN,
stringResource(R.string.setup_account),
s.offlineAccount?.let { if (s.offline) stringResource(R.string.setup_signed_in_offline, it) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in),
divider = false,
)
}
SectionTitle(stringResource(R.string.setup_tools), null)
ToolGrid(s, a)
}
1 -> {
val controller = s.controller
if (controller != null && a.controller != null) SettingsGroup(stringResource(R.string.setup_controller)) {
ControllerRows(host, s.oscMode, controller, a.controller)
}
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
}
2 -> {
SettingsGroup(stringResource(R.string.setup_session)) {
ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(s, a, host)
}
}
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs)
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
ToggleRow(
host, "offline", stringResource(R.string.setup_offline),
s.offlineAccount?.let { stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
}
3 -> {
SettingsGroup(stringResource(R.string.setup_launcher)) {
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
}
ToggleRow(
host, "home-screen", stringResource(R.string.setup_home),
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
ToggleRow(
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
s.launcherFullscreen,
) { a.onLauncherFullscreen(it) }
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
s.animationsEnabled, onChange = a.onAnimationsEnabled)
if (s.homeScreenEnabled) {
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
}
}
SettingsGroup(stringResource(R.string.setup_linux_apps)) {
ToggleRow(
host, "store-enabled", stringResource(R.string.setup_store),
if (s.storeEnabled) stringResource(R.string.setup_store_on)
else stringResource(R.string.setup_store_off),
s.storeEnabled,
) { a.onStoreEnabled(it) }
}
}
SettingsGroup(stringResource(R.string.setup_system_check)) {
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(MaterialTheme.colorScheme.surface)
.border(1.dp, LocalPalette.current.line, Shape14)) {
CheckRow(
if (gpuOk) CheckState.OK else CheckState.WARN,
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_supported)
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested)
else -> stringResource(R.string.setup_gpu_unsupported)
},
when (gpu.support) {
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.name, gpuName)
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.name, gpu.supportText.replaceFirstChar { it.lowercase() })
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
},
)
SettingsAnchor("runtime") { CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
stringResource(R.string.setup_runtime),
when {
s.busy -> null
s.removalPending -> stringResource(R.string.runtime_removal_incomplete)
!s.ready -> stringResource(R.string.setup_runtime_missing)
s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed))
else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed))
},
divider = !s.busy,
) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) } }
if (s.busy) {
Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
Text(if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage,
fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp))
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
}
SettingsAnchor("phantom-process") { CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
stringResource(R.string.setup_limit),
when (s.phantomProcessStatus) {
PhantomProcessStatus.ENABLED -> stringResource(R.string.setup_limit_on)
PhantomProcessStatus.UNSET -> stringResource(R.string.setup_limit_unset)
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
},
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) SecondaryButton(
if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_details), compact = true,
) { showLimitDetails = !showLimitDetails }
} }
AnimatedVisibility(showLimitDetails, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
Text(if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus) else PhantomProcessLimit.instructions(s.phantomProcessStatus),
fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp))
Actions {
if (limitBlocks) {
PrimaryButton(stringResource(R.string.setup_dev_options), compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton(stringResource(R.string.setup_use_wireless), compact = true, enabled = !processLimitBusy) { setProcessLimit(false) }
SecondaryButton(stringResource(R.string.setup_check_again), compact = true, onClick = a.onRefreshPhantomStatus)
} else if (s.phantomProcessStatus == PhantomProcessStatus.DISABLED) {
SecondaryButton(stringResource(R.string.setup_limit_turn_on), compact = true, enabled = !processLimitBusy) { setProcessLimit(true) }
}
}
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
}
CheckRow(
if (s.offlineAccount != null) CheckState.OK else CheckState.WARN,
stringResource(R.string.setup_account),
s.offlineAccount?.let { if (s.offline) stringResource(R.string.setup_signed_in_offline, it) else stringResource(R.string.setup_signed_in, it) }
?: stringResource(R.string.setup_sign_in),
divider = false,
)
}
}
SettingsAnchor("session-logs") { SettingsGroup(stringResource(R.string.setup_session)) {
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
SettingsAnchor("latest-logs") { ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs) }
SettingsAnchor("saved-logs") { ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs) }
} }
SettingsGroup("More support") {
SettingsAnchor("updates") { ActionRow("DroidDeck updates", "Build information and release channel", "Open", a.onOpenUpdates) }
}
}
/** The launcher's own tools as cards: four across, two by two on a narrow page. */
@Composable
private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
val columns = if (LocalNarrowPane.current) 2 else 4
val tools = listOf(
ToolSpec(Icons.Outlined.Folder, stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint), a.onFiles),
ToolSpec(Icons.Outlined.Extension, stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), a.onProtons),
ToolSpec(Icons.Outlined.Speed, stringResource(R.string.setup_tool_performance), stringResource(R.string.setup_tool_performance_hint), a.onPerformance),
ToolSpec(Icons.Outlined.VideogameAsset, stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint), a.onRoms),
)
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
for (row in tools.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit)
@Composable
@@ -217,7 +217,7 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill")
Box(
contentAlignment = Alignment.Center,
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2)
modifier = modifier.paneItem(LocalSettingsAnchorId.current?.let { "setting:$it:$text" } ?: "btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
@@ -0,0 +1,141 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.gpu.ScreenEffectLooks
import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.session.SessionPrefs
import java.util.Locale
import kotlin.math.roundToInt
/** Persistent graphics defaults shown in the launcher's settings page. */
@Composable
fun GraphicsSettingsContent(host: MenuHost, upscaler: Int, onUpscaler: (Int) -> Unit) {
val context = LocalContext.current
var effects by remember(context) { mutableStateOf(SessionPrefs.screenEffects(context)) }
var anisotropy by remember(context) { mutableIntStateOf(SessionPrefs.textureAnisotropy(context)) }
var lodBias by remember(context) { mutableStateOf(SessionPrefs.textureLodBias(context)) }
SettingsAdvanced("graphics-effects", stringResource(R.string.drawer_effects), targets = EFFECT_SETTING_IDS) {
ScreenEffectsSettings(
host = host,
effects = effects,
upscaler = upscaler,
onEffects = { value -> SessionPrefs.setScreenEffects(context, value); effects = value },
onUpscaler = onUpscaler,
grouped = false,
)
}
SettingsAdvanced("graphics-texture", stringResource(R.string.drawer_texture), targets = TEXTURE_SETTING_IDS) {
TextureFilteringSettings(
host = host,
anisotropy = anisotropy,
lodBias = lodBias,
onAnisotropy = { value -> SessionPrefs.setTextureAnisotropy(context, value); anisotropy = value },
onLodBias = { value -> SessionPrefs.setTextureLodBias(context, value); lodBias = value },
grouped = false,
)
}
}
/** Screen post-processing settings, shared by new sessions and applied live in the session drawer. */
@Composable
internal fun ScreenEffectsSettings(
host: MenuHost,
effects: ScreenEffects,
upscaler: Int,
onEffects: (ScreenEffects) -> Unit,
onUpscaler: (Int) -> Unit,
grouped: Boolean = true,
track: (String) -> Modifier = { Modifier },
) {
val look = ScreenEffectLooks.match(effects, upscaler)
val rows: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit = {
val looks = ScreenEffectLooks.LOOKS.map { it.name to it.name }
ChoiceRow(host, "effects-look", stringResource(R.string.drawer_look),
look?.desc ?: stringResource(R.string.drawer_effects_hint),
if (look == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + looks else looks,
look?.name ?: LOOK_CUSTOM, chipModifier = track("look"), hintLines = 2) { name ->
ScreenEffectLooks.LOOKS.firstOrNull { it.name == name }?.let { picked ->
picked.scalingMode?.let(onUpscaler)
onEffects(picked.effects)
}
}
ToggleRow(host, "effects-cas", stringResource(R.string.drawer_cas), null, effects.cas, chipModifier = track("cas")) { onEffects(effects.copy(cas = it)) }
SettingsAnchor("effects-cas-level") {
SliderRow(stringResource(R.string.drawer_cas_level), null, effects.casLevel, 0..100, step = 5, enabled = effects.cas,
format = { "$it%" }, modifier = track("cas-level").settingsTarget("effects-cas-level")) { onEffects(effects.copy(casLevel = it)) }
}
ToggleRow(host, "effects-fake-hdr", stringResource(R.string.drawer_fake_hdr), null, effects.hdr, chipModifier = track("fake-hdr")) { onEffects(effects.copy(hdr = it)) }
ToggleRow(host, "effects-deband", stringResource(R.string.drawer_deband), null, effects.deband, chipModifier = track("deband")) { onEffects(effects.copy(deband = it)) }
SettingsAnchor("effects-deband-strength") {
SliderRow(stringResource(R.string.drawer_deband_strength), null, effects.debandStrength, 0..200, step = 5, enabled = effects.deband,
format = { "$it%" }, modifier = track("deband-strength").settingsTarget("effects-deband-strength")) { onEffects(effects.copy(debandStrength = it)) }
}
SettingsAnchor("effects-brightness") {
SliderRow(stringResource(R.string.drawer_brightness), null, effects.brightness, -100..100, step = 2,
format = ::signed, modifier = track("brightness").settingsTarget("effects-brightness")) { onEffects(effects.copy(brightness = it)) }
}
SettingsAnchor("effects-contrast") {
SliderRow(stringResource(R.string.drawer_contrast), null, effects.contrast, -100..100, step = 2,
format = ::signed, modifier = track("contrast").settingsTarget("effects-contrast")) { onEffects(effects.copy(contrast = it)) }
}
SettingsAnchor("effects-gamma") {
SliderRow(stringResource(R.string.drawer_gamma), null, (effects.gamma * 100f).roundToInt(), 50..300, step = 5,
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma").settingsTarget("effects-gamma")) { onEffects(effects.copy(gamma = it / 100f)) }
}
SettingsAnchor("effects-saturation") {
SliderRow(stringResource(R.string.drawer_saturation), null, effects.saturation, 0..200, step = 5,
format = { "$it%" }, modifier = track("saturation").settingsTarget("effects-saturation")) { onEffects(effects.copy(saturation = it)) }
}
ToggleRow(host, "effects-fxaa", stringResource(R.string.drawer_fxaa), null, effects.fxaa, chipModifier = track("fxaa")) { onEffects(effects.copy(fxaa = it)) }
ToggleRow(host, "effects-toon", stringResource(R.string.drawer_toon), null, effects.toon, chipModifier = track("toon")) { onEffects(effects.copy(toon = it)) }
ToggleRow(host, "effects-crt", stringResource(R.string.drawer_crt), null, effects.crt, chipModifier = track("crt")) { onEffects(effects.copy(crt = it)) }
ToggleRow(host, "effects-ntsc", stringResource(R.string.drawer_ntsc), null, effects.ntsc, chipModifier = track("ntsc")) { onEffects(effects.copy(ntsc = it)) }
}
if (grouped) SettingsGroup(stringResource(R.string.drawer_effects), content = rows)
else androidx.compose.foundation.layout.Column(Modifier.fillMaxWidth(), content = rows)
}
/** DXVK filtering options saved as shared defaults and consumed by each game's next launch. */
@Composable
internal fun TextureFilteringSettings(
host: MenuHost,
anisotropy: Int,
lodBias: String,
onAnisotropy: (Int) -> Unit,
onLodBias: (String) -> Unit,
grouped: Boolean = true,
track: (String) -> Modifier = { Modifier },
) {
val rows: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit = {
ChoiceRow(host, "textures-anisotropy", stringResource(R.string.drawer_anisotropy), stringResource(R.string.drawer_texture_hint),
SessionPrefs.textureAnisotropyChoices, anisotropy, chipModifier = track("anisotropy"), onPick = onAnisotropy)
ChoiceRow(host, "textures-sharpness", stringResource(R.string.drawer_texture_sharpness), stringResource(R.string.drawer_texture_hint),
SessionPrefs.textureLodBiasChoices, lodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
chipModifier = track("texture-sharpness"), onPick = onLodBias)
}
if (grouped) SettingsGroup(stringResource(R.string.drawer_texture), content = rows)
else androidx.compose.foundation.layout.Column(Modifier.fillMaxWidth(), content = rows)
}
private const val LOOK_CUSTOM = "__custom__"
private val EFFECT_SETTING_IDS = setOf(
"effects-look", "effects-cas", "effects-cas-level", "effects-fake-hdr", "effects-deband",
"effects-deband-strength", "effects-brightness", "effects-contrast", "effects-gamma",
"effects-saturation", "effects-fxaa", "effects-toon", "effects-crt", "effects-ntsc",
)
private val TEXTURE_SETTING_IDS = setOf("textures-anisotropy", "textures-sharpness")
private fun signed(value: Int) = if (value > 0) "+$value" else value.toString()
@@ -1,7 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
@@ -162,8 +161,6 @@ class ModeSettingsActions(
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
val pageScroll = androidx.compose.foundation.rememberScrollState()
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
androidx.compose.runtime.LaunchedEffect(Unit) {
@@ -178,6 +175,26 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onBack = a.onDismiss,
scroll = pageScroll,
) {
for (category in SettingsCategory.values()) {
ModeSettingsContent(s, a, category, host, firstChip)
}
}
}
/** Renders only the mode-specific rows for one Settings hub category. */
@Composable
fun ModeSettingsContent(
s: ModeSettings,
a: ModeSettingsActions,
category: SettingsCategory,
host: MenuHost,
firstChip: androidx.compose.ui.focus.FocusRequester? = null,
) {
val steam = s.mode == SessionService.MODE_STEAM
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
when (category) {
SettingsCategory.DISPLAY -> {
SettingsGroup(stringResource(R.string.mode_display)) {
val default = SessionPrefs.defaultResolutionCap(s.mode)
var editCustom by remember { mutableStateOf(false) }
@@ -188,12 +205,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
.map { (cap, label) -> cap to (if (cap == default) stringResource(R.string.mode_res_default, label) else label) } +
(CUSTOM to (custom?.let { stringResource(R.string.mode_res_custom_value, it.first, it.second) } ?: stringResource(R.string.mode_res_custom))),
if (custom != null) CUSTOM else s.resolutionCap, note = stringResource(R.string.mode_res_note),
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
chipModifier = firstChip?.let { androidx.compose.ui.Modifier.focusRequester(it) } ?: androidx.compose.ui.Modifier,
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
)
ChoiceRow(
host, "shape", stringResource(R.string.mode_ratio),
if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto),
"Shared · " + if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto),
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
ChoiceRow(
@@ -203,15 +220,15 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = a.onFpsLimit,
)
ChoiceRow(
host, "upscaler", stringResource(R.string.drawer_scaling), "How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.",
host, "upscaler", stringResource(R.string.drawer_scaling), "Shared · Applies next session.",
com.droiddeck.launcher.session.SessionPrefs.upscalerChoices, s.upscaler,
note = "The sharpening modes work only when the session is smaller than the screen; Linear, Nearest and Sharpen only work at any size. Costs a little GPU time.",
onPick = a.onUpscaler,
)
SliderRow(
stringResource(R.string.drawer_scaling_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
SettingsAnchor("upscale-sharpness") { SliderRow(
stringResource(R.string.drawer_scaling_sharpness), "Shared · Applies next session.", s.upscaleSharpness, 0..100, step = 5,
enabled = s.upscaler != 0, format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
) }
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
@@ -226,28 +243,24 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
}
if (s.forceFullscreen != null) SettingsGroup(stringResource(R.string.mode_display)) {
ToggleRow(host, "fill", stringResource(R.string.mode_fill), stringResource(R.string.mode_fill_hint), s.forceFullscreen, onChange = a.onForceFullscreen)
if (s.stretch16x9 != null) ToggleRow(host, "stretch169", stringResource(R.string.mode_stretch169), stringResource(R.string.mode_stretch169_hint), s.stretch16x9, onChange = a.onStretch16x9)
}
if (steam) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(host, "mangoapp", stringResource(R.string.mode_mangoapp),
if (s.steamDeckMode) stringResource(R.string.mode_mangoapp_hint) else "Enable Steam Deck mode in Sessions to use this overlay. Applies next session.",
s.mangoapp, enabled = s.steamDeckMode, onChange = a.onMangoapp)
}
}
SettingsCategory.CONTROLS -> {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
host, "touch", stringResource(R.string.mode_touch), "Shared · Applies next session.",
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
@@ -257,14 +270,63 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
SettingsCategory.AUDIO -> {
if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ChoiceRow(
host, "clientAudio", "Steam-menu audio", stringResource(R.string.mode_client_audio_hint),
listOf("classic" to stringResource(R.string.mode_client_audio_classic), "directaudio" to stringResource(R.string.mode_client_audio_direct)), if (s.clientDirectAudio) "directaudio" else "classic",
onPick = { id -> a.onClientDirectAudio(id == "directaudio") },
)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), null, s.mic, onChange = a.onMic)
}
}
SettingsCategory.LIBRARY -> {
if (steam && s.addedGamesDirs != null) SettingsAnchor("added-games") { SettingsGroup(stringResource(R.string.mode_added_games)) {
for (dir in s.addedGamesDirs) {
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
SettingsAnchor("added-games:${dir}") { ActionRow(
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) stringResource(R.string.mode_added_none) else pluralStringResource(R.plurals.mode_added_count, n, n)) + ". " + stringResource(R.string.added_games_forget_hint),
stringResource(R.string.common_forget), onClick = { a.onForgetAddedGamesDir(dir) },
) }
}
SettingsAnchor("added-games-add") { ActionRow(
if (s.addedGamesDirs.isEmpty()) stringResource(R.string.mode_games_folder) else stringResource(R.string.mode_games_folder_another),
stringResource(R.string.added_games_import_hint), stringResource(R.string.common_add_ellipsis), onClick = a.onPickAddedGamesDir,
) }
ToggleRow(host, "addedArt", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt, onChange = a.onAddedGamesArt)
for (g in s.addedGames) ChoiceRow(
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
note = stringResource(R.string.mode_added_exe_note),
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
} }
if (steam && s.gameStorage != null) SettingsAnchor("storage") { SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
val options = buildList {
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
add("off" to stringResource(R.string.mode_storage_internal))
for ((label, path) in s.storageOptions) add(path to label)
if (custom) add(s.gameStorage to stringResource(R.string.mode_storage_folder, s.gameStorage))
}
val open = host.open == "storage"
SettingsAnchor("storage-location") { SettingsRow(stringResource(R.string.mode_second_library), stringResource(R.string.second_library_import_hint), highlighted = open) {
androidx.compose.foundation.layout.Box {
ValueChip(options.firstOrNull { it.first == s.gameStorage }?.second?.substringBefore(" -") ?: "-", open) { host.open = if (open) null else "storage" }
AnchoredMenu(open, onDismiss = { if (host.open == "storage") host.open = null }, title = stringResource(R.string.mode_second_library), note = stringResource(R.string.mode_storage_note)) { firstItemFocus ->
options.forEachIndexed { index, (path, label) -> MenuItem(label, checked = path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
a.onGameStorage(path, if (path.isEmpty() || path == "off") "" else label.substringBefore(" ·")); host.open = null
} }
MenuItem(stringResource(R.string.mode_choose_folder), checked = false) { host.open = null; a.onPickGameStorageFolder() }
}
}
} }
} }
}
SettingsCategory.SESSIONS -> {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
@@ -279,9 +341,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = a.onSuspendPolicy,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto),
if (s.pipSupported) null else "Picture-in-picture is unavailable on this device.",
s.pipAutoEnter, enabled = s.pipSupported, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
@@ -290,7 +353,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
if (steam) SettingsGroup(stringResource(R.string.mode_decky)) {
if (steam) SettingsAnchor("decky") { SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
@@ -302,7 +365,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
}
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
SettingsAnchor("decky-loader") { SettingsRow(stringResource(R.string.mode_decky_loader), status) {
val action = when {
s.deckyStage != null -> stringResource(R.string.common_working)
s.deckyChecking -> stringResource(R.string.common_checking)
@@ -323,16 +386,17 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
enabled = s.deckyStage == null && !s.deckySessionRunning,
) { confirmDeckyRemoval = true }
}
}
if (s.deckyInstalled != null) ToggleRow(
} }
ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
if (s.deckyInstalled == null) "Install Decky Loader above to enable it."
else if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
else stringResource(R.string.mode_decky_off),
s.deckyEnabled,
enabled = !s.deckySessionRunning && s.deckyStage == null,
enabled = s.deckyInstalled != null && !s.deckySessionRunning && s.deckyStage == null,
onChange = a.onDeckyEnabled,
)
SettingsRow(
SettingsAnchor("decky-plugins") { SettingsRow(
"Plugins",
"Install a plugin ZIP for the next Steam session.",
) {
@@ -341,27 +405,24 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
}
} }
if (s.deckyStage != null && s.deckyPercent >= 0) {
LinearProgressIndicator(
progress = { s.deckyPercent / 100f },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
} }
if (steam && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "steamdeck", stringResource(R.string.mode_deck_mode),
stringResource(R.string.mode_deck_mode_hint),
s.steamDeckMode, onChange = a.onSteamDeckMode,
)
if (s.steamDeckMode) ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
if (s.steamDeckMode) SettingsAnchor("channel") {
SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
}
else ChoiceRow(
host, "channel", stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_hint),
listOf("publicbeta" to stringResource(R.string.mode_branch_public), "steamdeck_publicbeta" to stringResource(R.string.mode_branch_deck_beta)), s.steamChannel,
@@ -375,7 +436,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
else -> stringResource(R.string.mode_wifi_discovery_hint)
}
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
SettingsAnchor("wifi") { SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
@@ -386,39 +447,25 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
}
}
}
} }
}
if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
for (dir in s.addedGamesDirs) {
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
ActionRow(
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) stringResource(R.string.mode_added_none) else pluralStringResource(R.plurals.mode_added_count, n, n)) + ". " + stringResource(R.string.added_games_forget_hint),
stringResource(R.string.common_forget), onClick = { a.onForgetAddedGamesDir(dir) },
)
}
ActionRow(
if (s.addedGamesDirs.isEmpty()) stringResource(R.string.mode_games_folder) else stringResource(R.string.mode_games_folder_another),
stringResource(R.string.added_games_import_hint),
stringResource(R.string.common_add_ellipsis), onClick = a.onPickAddedGamesDir,
)
ToggleRow(
host, "addedArt", stringResource(R.string.mode_added_art),
stringResource(R.string.mode_added_art_hint),
s.addedGamesArt, onChange = a.onAddedGamesArt,
)
for (g in s.addedGames) ChoiceRow(
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
note = stringResource(R.string.mode_added_exe_note),
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
ChoiceRow(host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint),
listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer,
note = stringResource(R.string.mode_renderer_note), onPick = a.onRenderer)
}
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (s.syncBackend != null) SettingsRow(
stringResource(R.string.sync_backend_title),
stringResource(R.string.sync_backend_hint),
) {
SegmentedTabs(
}
SettingsCategory.COMPATIBILITY -> {
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsAnchor("gpu-drivers") { SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
} }
}
if (steam && s.fexPreset != null) {
SettingsAdvanced("game-settings", stringResource(R.string.game_settings_title), targets = setOf("fex", "syncBackend", "game-env")) {
if (s.syncBackend != null) SettingsAnchor("syncBackend") { SettingsRow(
stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint),
) { SegmentedTabs(
listOf(
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
@@ -426,78 +473,22 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
),
s.syncBackend,
) { id -> host.open = null; a.onSyncBackend(id) }
) { id -> host.open = null; a.onSyncBackend(id) } } }
ChoiceRow(
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
SettingsAnchor("game-env") { GameEnvironmentRow() }
}
ChoiceRow(
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
GameEnvironmentRow()
if (s.forceFullscreen != null) ToggleRow(
host, "fill", stringResource(R.string.mode_fill),
stringResource(R.string.mode_fill_hint),
s.forceFullscreen, onChange = a.onForceFullscreen,
)
if (s.stretch16x9 != null) ToggleRow(
host, "stretch169", stringResource(R.string.mode_stretch169),
stringResource(R.string.mode_stretch169_hint),
s.stretch16x9, onChange = a.onStretch16x9,
)
}
if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ChoiceRow(
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
listOf("classic" to stringResource(R.string.mode_client_audio_classic), "directaudio" to stringResource(R.string.mode_client_audio_direct)), if (s.clientDirectAudio) "directaudio" else "classic",
onPick = { id -> a.onClientDirectAudio(id == "directaudio") },
)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
val options = buildList {
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
add("off" to stringResource(R.string.mode_storage_internal))
for ((label, path) in s.storageOptions) add(path to label)
if (custom) add(s.gameStorage to stringResource(R.string.mode_storage_folder, s.gameStorage))
}
val open = host.open == "storage"
SettingsRow(
stringResource(R.string.mode_second_library),
stringResource(R.string.second_library_import_hint),
highlighted = open,
) {
androidx.compose.foundation.layout.Box {
ValueChip(options.firstOrNull { it.first == s.gameStorage }?.second?.substringBefore(" -") ?: "-", open) { host.open = if (open) null else "storage" }
AnchoredMenu(
open, onDismiss = { if (host.open == "storage") host.open = null }, title = stringResource(R.string.mode_second_library),
note = stringResource(R.string.mode_storage_note),
) { firstItemFocus ->
options.forEachIndexed { index, (path, label) ->
MenuItem(label, checked = path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
a.onGameStorage(path, if (path.isEmpty() || path == "off") "" else label.substringBefore(" ·"))
host.open = null
}
}
MenuItem(stringResource(R.string.mode_choose_folder), checked = false) { host.open = null; a.onPickGameStorageFolder() }
}
}
}
ToggleRow(
host, "storageDiagnostics", stringResource(R.string.mode_storage_diagnostics),
stringResource(R.string.mode_storage_diagnostics_hint), s.storageDiagnostics,
onChange = a.onStorageDiagnostics,
)
}
if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
ChoiceRow(
host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint),
listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer,
note = stringResource(R.string.mode_renderer_note),
onPick = a.onRenderer,
)
}
SettingsCategory.SUPPORT -> {
if (steam && s.gameStorage != null) SettingsAdvanced("storage-diagnostics", stringResource(R.string.mode_storage), targets = setOf("storageDiagnostics")) {
ToggleRow(host, "storageDiagnostics", stringResource(R.string.mode_storage_diagnostics), stringResource(R.string.mode_storage_diagnostics_hint), s.storageDiagnostics, onChange = a.onStorageDiagnostics)
}
}
SettingsCategory.GENERAL -> Unit
}
if (explainWifiDiscovery) AlertDialog(
onDismissRequest = { explainWifiDiscovery = false },
@@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -71,7 +71,6 @@ fun PerformancePage(
val coreItems = cores.map { it.core to it.label }
SettingsPage(
host, title = stringResource(R.string.perf_title),
lede = stringResource(R.string.perf_lede),
onBack = onDismiss,
) {
SettingsGroup(stringResource(R.string.perf_client_cores_group)) {
@@ -80,17 +79,17 @@ fun PerformancePage(
stringResource(R.string.perf_override_hint),
clientOverride, onChange = onClientOverride,
)
MultiRow(
SettingsAnchor("clientCores") { MultiRow(
host, "clientCores", stringResource(R.string.perf_client_cores), if (clientOverride) stringResource(R.string.perf_client_cores_on) else stringResource(R.string.perf_client_cores_off),
coreItems, clientCores, enabled = clientOverride, onToggle = onClientCore,
)
) }
}
SettingsGroup(stringResource(R.string.perf_game_cores_group)) {
MultiRow(
SettingsAnchor("gameCores") { MultiRow(
host, "gameCores", stringResource(R.string.perf_game_cores),
stringResource(R.string.perf_game_cores_hint),
coreItems, gameCores, onToggle = onGameCore,
)
) }
}
SettingsGroup(stringResource(R.string.perf_client_ui)) {
ToggleRow(
@@ -162,7 +161,7 @@ fun PerformancePage(
SettingsGroup(stringResource(R.string.perf_identity)) {
var draft by remember(guestHostname) { mutableStateOf(guestHostname) }
val valid = SessionPrefs.validGuestHostname(draft) != null
SettingsRow(
SettingsAnchor("hostname") { SettingsRow(
stringResource(R.string.perf_hostname),
if (valid || draft.isBlank()) stringResource(R.string.perf_hostname_hint, SessionPrefs.DEFAULT_GUEST_HOSTNAME)
else stringResource(R.string.perf_hostname_invalid),
@@ -174,9 +173,9 @@ fun PerformancePage(
},
singleLine = true, isError = !valid && draft.isNotBlank(),
placeholder = { Text(SessionPrefs.DEFAULT_GUEST_HOSTNAME) },
modifier = Modifier.width(220.dp),
modifier = Modifier.widthIn(max = 220.dp),
)
}
} }
}
if (phantomWarning != null) {
Spacer(Modifier.height(16.dp))
@@ -36,8 +36,6 @@ fun ProtonPage(
SettingsPage(
host,
title = stringResource(R.string.setup_tool_protons),
eyebrow = stringResource(R.string.setup_title),
lede = stringResource(R.string.proton_lede),
onBack = onBack,
) {
if (!runtimeReady) Text(
@@ -116,11 +116,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.gpu.ScreenEffectLooks
import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.ComponentsManager
@@ -386,8 +385,9 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
DRAWER_PAGE_EFFECTS -> {
ScreenEffectsGroup(host, a) { key -> focus.track(page, key) }
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
ScreenEffectsSettings(host, a.effects, a.upscaler, a.onEffects, a.onUpscaler) { key -> focus.track(page, key) }
TextureFilteringSettings(host, a.textureAnisotropy, a.textureLodBias,
a.onTextureAnisotropy, a.onTextureLodBias) { key -> focus.track(page, key) }
}
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
DRAWER_PAGE_CONTROLS -> {
@@ -696,61 +696,6 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
/** The Look row's value when the rows below match no Look. */
private const val LOOK_CUSTOM = "custom"
/**
* Screen effects on the Effects page: a Look row that moves every row under it, then the rows
* themselves: switches and sliders the d-pad steps (SliderRow), so a pad drives them.
*/
@Composable
private fun ScreenEffectsGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
val e = a.effects
val look = ScreenEffectLooks.match(e, a.upscaler)
SettingsGroup(stringResource(R.string.drawer_effects)) {
val looks = ScreenEffectLooks.LOOKS.map { it.name to it.name }
ChoiceRow(host, "look", stringResource(R.string.drawer_look),
look?.desc ?: stringResource(R.string.drawer_effects_hint),
if (look == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + looks else looks,
look?.name ?: LOOK_CUSTOM, chipModifier = track("look"), hintLines = 2) { name ->
ScreenEffectLooks.LOOKS.firstOrNull { it.name == name }?.let { picked ->
picked.scalingMode?.let(a.onUpscaler)
a.onEffects(picked.effects)
}
}
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5, enabled = e.cas,
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5, enabled = e.deband,
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
ToggleRow(host, "fxaa", stringResource(R.string.drawer_fxaa), null, e.fxaa, chipModifier = track("fxaa")) { a.onEffects(e.copy(fxaa = it)) }
ToggleRow(host, "toon", stringResource(R.string.drawer_toon), null, e.toon, chipModifier = track("toon")) { a.onEffects(e.copy(toon = it)) }
ToggleRow(host, "crt", stringResource(R.string.drawer_crt), null, e.crt, chipModifier = track("crt")) { a.onEffects(e.copy(crt = it)) }
ToggleRow(host, "ntsc", stringResource(R.string.drawer_ntsc), null, e.ntsc, chipModifier = track("ntsc")) { a.onEffects(e.copy(ntsc = it)) }
}
}
/** Texture filtering on the Effects page: DXVK options for the next DirectX 9-11 launch. */
@Composable
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
SettingsGroup(stringResource(R.string.drawer_texture)) {
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), stringResource(R.string.drawer_texture_hint),
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), stringResource(R.string.drawer_texture_hint),
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
}
}
private fun signed(v: Int) = if (v > 0) "+$v" else "$v"
/** Display, Effects, Controls, Components, Settings. */
const val DRAWER_PAGES = 5
@@ -0,0 +1,72 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.focus.onFocusChanged
import kotlinx.coroutines.flow.filterNotNull
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
@Composable
internal fun SettingsCategoryPage(
s: FrontEndState,
a: FrontEndActions,
category: SettingsCategory,
mode: String,
target: String?,
onOpenDeveloperOptions: () -> Unit,
onRequestWirelessAdb: (Boolean) -> Unit,
modeContent: @Composable (MenuHost) -> Unit,
) {
val host = rememberMenuHost()
val entry = remember { FocusRequester() }
val frontFocus = LocalFrontFocus.current
var hasFocus by remember { mutableStateOf(false) }
var lastItem by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(frontFocus) {
snapshotFlow { if (hasFocus) frontFocus?.last else null }.filterNotNull().collect { lastItem = it }
}
val back = { if (host.open != null) host.open = null else a.onPageBack() }
BackHandler(enabled = host.open != null, onBack = back)
LaunchedEffect(category, mode) {
if (target == null) {
focusWithinFrames({ hasFocus }) {
val id = lastItem
if (id != null && (frontFocus?.attached?.get(id) ?: 0) > 0) frontFocus!!.items.getValue(id)
else entry
}
}
}
CompositionLocalProvider(LocalSettingsTarget provides target, LocalSettingsAnchorsEnabled provides true) {
SettingsPage(
host, category.title, back,
) {
if (category == SettingsCategory.DISPLAY || category == SettingsCategory.SESSIONS) {
SegmentedTabs(
settingsSessionModes, mode,
Modifier.padding(bottom = 10.dp), a.onSettingsMode,
)
}
Column(Modifier.fillMaxWidth().focusRequester(entry).onFocusChanged { hasFocus = it.hasFocus }.focusGroup()) {
if (category != SettingsCategory.SESSIONS || mode == "steam") {
SetupContent(s, a, category, host, onOpenDeveloperOptions, onRequestWirelessAdb)
}
modeContent(host)
}
}
}
}
@@ -0,0 +1,391 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DisplaySettings
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Gamepad
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import android.text.Editable
import android.text.TextWatcher
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.core.PhantomProcessLimit
import kotlinx.coroutines.flow.filterNotNull
/** The settings taxonomy is shared by the hub, category pages, and the searchable index. */
enum class SettingsCategory(val title: String, val description: String) {
GENERAL("General", "Launcher appearance and home-screen behavior"),
DISPLAY("Display & graphics", "Resolution, effects, scaling, and frame generation"),
CONTROLS("Controls & input", "Controller layout, touch behavior, and button mapping"),
AUDIO("Audio & microphone", "Session audio and microphone options"),
LIBRARY("Library & storage", "Game folders, storage, ROMs, and file access"),
SESSIONS("Sessions", "Session startup, suspend, and client behavior"),
COMPATIBILITY("Compatibility & performance", "Proton, components, and performance tools"),
SUPPORT("Support & diagnostics", "System checks, logs, runtime, and app updates"),
}
data class SettingsEntry(
val target: String,
val category: SettingsCategory,
val title: String,
val description: String,
val aliases: List<String> = emptyList(),
/** Null means the target is available regardless of Steam/Desktop mode. */
val mode: String? = null,
) {
internal fun matches(query: String): Boolean {
val q = query.trim().lowercase()
if (q.isEmpty()) return false
return sequenceOf(title, description, category.title).plus(aliases.asSequence())
.any { it.lowercase().contains(q) }
}
}
/** Search data points into the existing settings rows and specialist pages; it stores no values. */
object SettingsCatalog {
val entries: List<SettingsEntry> = listOf(
SettingsEntry("theme", SettingsCategory.GENERAL, "Theme", "Choose the launcher color theme", listOf("appearance", "colors")),
SettingsEntry("home-screen", SettingsCategory.GENERAL, "Home screen", "Enable DroidDeck as an Android home app", listOf("launcher", "default launcher")),
SettingsEntry("default-home", SettingsCategory.GENERAL, "Default home app", "Choose DroidDeck as the Android home app", listOf("home app", "launcher app")),
SettingsEntry("launcher-fullscreen", SettingsCategory.GENERAL, "Fullscreen", "Show the launcher without Android system bars", listOf("immersive")),
SettingsEntry("launcher-animations", SettingsCategory.GENERAL, "Animations", "Enable or disable launcher motion", listOf("motion")),
SettingsEntry("store-enabled", SettingsCategory.GENERAL, "Linux app store", "Show the Flathub Store in the launcher", listOf("store", "flathub")),
SettingsEntry("res", SettingsCategory.DISPLAY, "Resolution", "Set the next session resolution", listOf("720p", "900p", "1080p", "native"), "steam"),
SettingsEntry("shape", SettingsCategory.DISPLAY, "Aspect ratio", "Choose how the session fits the screen", listOf("shape", "ratio"), "steam"),
SettingsEntry("fps", SettingsCategory.DISPLAY, "Frame-rate limit", "Limit the session frame rate", listOf("fps", "refresh rate"), "steam"),
SettingsEntry("upscaler", SettingsCategory.DISPLAY, "Upscaling", "Choose the session image scaling filter", listOf("sharpness", "scaling"), "steam"),
SettingsEntry("upscale-sharpness", SettingsCategory.DISPLAY, "Upscaling sharpness", "Adjust image sharpening", listOf("sharpening"), "steam"),
SettingsEntry("hdr", SettingsCategory.DISPLAY, "HDR", "Enable HDR output when supported", listOf("high dynamic range"), "steam"),
SettingsEntry("fill", SettingsCategory.DISPLAY, "Fill screen", "Stretch the session to fill the display", listOf("force fullscreen"), "steam"),
SettingsEntry("stretch169", SettingsCategory.DISPLAY, "Stretch to 16:9", "Stretch the session image to a 16:9 shape", listOf("widescreen stretch"), "steam"),
SettingsEntry("gpu-drivers", SettingsCategory.COMPATIBILITY, "GPU drivers", "Manage Android and Linux display drivers", listOf("turnip", "adreno", "vulkan"), "steam"),
SettingsEntry("fg", SettingsCategory.DISPLAY, "Frame generation", "Choose a frame-generation mode or import Lossless Scaling", listOf("lossless scaling", "lsfg")),
SettingsEntry("effects-look", SettingsCategory.DISPLAY, "Screen effects preset", "Choose a preset for image effects", listOf("look", "post-processing")),
SettingsEntry("effects-cas", SettingsCategory.DISPLAY, "Contrast adaptive sharpening", "Enable contrast adaptive sharpening", listOf("cas", "sharpness")),
SettingsEntry("effects-cas-level", SettingsCategory.DISPLAY, "Sharpening strength", "Adjust contrast adaptive sharpening", listOf("cas level")),
SettingsEntry("effects-fake-hdr", SettingsCategory.DISPLAY, "Fake HDR", "Add an HDR-style image effect", listOf("hdr effect")),
SettingsEntry("effects-deband", SettingsCategory.DISPLAY, "Debanding", "Reduce color banding in gradients", listOf("banding")),
SettingsEntry("effects-deband-strength", SettingsCategory.DISPLAY, "Debanding strength", "Adjust color banding reduction", listOf("banding strength")),
SettingsEntry("effects-brightness", SettingsCategory.DISPLAY, "Brightness", "Adjust the session image brightness", listOf("light")),
SettingsEntry("effects-contrast", SettingsCategory.DISPLAY, "Contrast", "Adjust the session image contrast", listOf("image contrast")),
SettingsEntry("effects-gamma", SettingsCategory.DISPLAY, "Gamma", "Adjust the session image gamma", listOf("midtones")),
SettingsEntry("effects-saturation", SettingsCategory.DISPLAY, "Saturation", "Adjust the session image saturation", listOf("color intensity")),
SettingsEntry("effects-fxaa", SettingsCategory.DISPLAY, "FXAA", "Enable fast approximate anti-aliasing", listOf("anti-aliasing")),
SettingsEntry("effects-toon", SettingsCategory.DISPLAY, "Toon effect", "Enable the toon image effect", listOf("cartoon")),
SettingsEntry("effects-crt", SettingsCategory.DISPLAY, "CRT effect", "Enable the CRT image effect", listOf("scanlines")),
SettingsEntry("effects-ntsc", SettingsCategory.DISPLAY, "NTSC effect", "Enable the NTSC image effect", listOf("composite video")),
SettingsEntry("textures-anisotropy", SettingsCategory.DISPLAY, "Texture anisotropy", "Choose texture filtering anisotropy", listOf("anisotropic filtering")),
SettingsEntry("textures-sharpness", SettingsCategory.DISPLAY, "Texture sharpness", "Adjust texture level-of-detail bias", listOf("lod bias", "texture filtering")),
SettingsEntry("touch", SettingsCategory.CONTROLS, "Touch mode", "Choose how touch input behaves in a session", listOf("touchpad", "direct touch"), "steam"),
SettingsEntry("controller-osc", SettingsCategory.CONTROLS, "On-screen controls", "Choose when the touch controls appear", listOf("overlay", "controller overlay"), "steam"),
SettingsEntry("controller", SettingsCategory.CONTROLS, "Controller type", "Choose the controller profile exposed to Steam", listOf("xbox", "steam deck"), "steam"),
SettingsEntry("back-actions", SettingsCategory.CONTROLS, "Back-button actions", "Choose the order of the Steam menu and Quick Access Menu", listOf("b button", "menu button", "qam")),
SettingsEntry("controller-mapping", SettingsCategory.CONTROLS, "Button mapping", "Remap controller buttons", listOf("mapping", "remap")),
SettingsEntry("controller-tint", SettingsCategory.CONTROLS, "Controller color", "Choose the on-screen controller color", listOf("tint")),
SettingsEntry("controller-layout", SettingsCategory.CONTROLS, "Controller layout", "Edit or reset the touch controller layout", listOf("custom layout")),
SettingsEntry("controller-reset", SettingsCategory.CONTROLS, "Reset controller settings", "Reset the on-screen controller configuration", listOf("reset all")),
SettingsEntry("controller-opacity", SettingsCategory.CONTROLS, "Controller opacity", "Adjust the on-screen controller opacity", listOf("transparency")),
SettingsEntry("controller-size", SettingsCategory.CONTROLS, "Controller size", "Adjust the on-screen controller size", listOf("scale")),
SettingsEntry("controller-stick-click", SettingsCategory.CONTROLS, "Stick click", "Enable stick-click controls", listOf("l3", "r3")),
SettingsEntry("controller-adaptive", SettingsCategory.CONTROLS, "Adaptive sticks", "Adjust stick behavior to the active game", listOf("stick sensitivity")),
SettingsEntry("controller-rumble", SettingsCategory.CONTROLS, "Controller rumble", "Enable controller vibration", listOf("vibration")),
SettingsEntry("controller-steam", SettingsCategory.CONTROLS, "Steam button", "Show the Steam button on the virtual controller", listOf("steam menu")),
SettingsEntry("controller-qam", SettingsCategory.CONTROLS, "Quick Access button", "Show the Quick Access button on the virtual controller", listOf("qam")),
SettingsEntry("controller-keyboard", SettingsCategory.CONTROLS, "On-screen keyboard button", "Show the keyboard button on the virtual controller", listOf("keyboard")),
SettingsEntry("controller-actions", SettingsCategory.CONTROLS, "Controller layout and mapping", "Edit the on-screen layout, remap buttons, or reset controller settings", listOf("color", "opacity", "size", "stick click", "adaptive sticks", "rumble", "steam button", "qam button", "keyboard button", "layout", "mapping", "reset")),
SettingsEntry("da", SettingsCategory.AUDIO, "Direct audio", "Configure direct game audio", listOf("audio output"), "steam"),
SettingsEntry("clientAudio", SettingsCategory.AUDIO, "Steam-menu audio", "Choose the audio mode for Steam menus", listOf("client audio", "steam client audio"), "steam"),
SettingsEntry("mic", SettingsCategory.AUDIO, "Microphone", "Allow session microphone input", listOf("recording", "voice chat"), "steam"),
SettingsEntry("added-games", SettingsCategory.LIBRARY, "Added games", "Manage imported game folders and launch files", listOf("non-steam games", "exe"), "steam"),
SettingsEntry("addedArt", SettingsCategory.LIBRARY, "Added game artwork", "Show artwork for imported games", listOf("game covers", "covers"), "steam"),
SettingsEntry("storage", SettingsCategory.LIBRARY, "Game storage", "Choose where Steam stores games", listOf("second library", "sd card"), "steam"),
SettingsEntry("storage-location", SettingsCategory.LIBRARY, "Second library", "Import or remove a second Steam library", listOf("library location", "sd card folder"), "steam"),
SettingsEntry("added-games-add", SettingsCategory.LIBRARY, "Add a game folder", "Import another folder of games", listOf("add games directory", "import folder"), "steam"),
SettingsEntry("roms", SettingsCategory.LIBRARY, "ROM folders", "Choose the folder scanned for ROMs", listOf("emulator games")),
SettingsEntry("files", SettingsCategory.LIBRARY, "File manager", "Browse files in the Linux runtime", listOf("file browser")),
SettingsEntry("suspend", SettingsCategory.SESSIONS, "Suspend behavior", "Choose how sessions respond to suspend", listOf("sleep", "suspend policy"), "steam"),
SettingsEntry("offline", SettingsCategory.SESSIONS, "Offline mode", "Use the signed-in account in offline mode", listOf("offline account")),
SettingsEntry("pip-auto", SettingsCategory.SESSIONS, "Picture-in-picture", "Automatically enter picture-in-picture", listOf("pip"), "steam"),
SettingsEntry("steam-startup", SettingsCategory.SESSIONS, "Start Steam on launch", "Start Steam automatically with DroidDeck", listOf("autostart"), "steam"),
SettingsEntry("steamdeck", SettingsCategory.SESSIONS, "Steam Deck mode", "Use the Steam Deck client interface", listOf("big picture", "deck mode"), "steam"),
SettingsEntry("mangoapp", SettingsCategory.DISPLAY, "Performance overlay", "Show the MangoHud performance overlay", listOf("mangohud", "mangoapp"), "steam"),
SettingsEntry("channel", SettingsCategory.SESSIONS, "Steam branch", "Choose the Steam client update branch", listOf("beta", "client channel"), "steam"),
SettingsEntry("wifi", SettingsCategory.SESSIONS, "Wi-Fi discovery", "Enable local network discovery for Steam", listOf("network", "remote play"), "steam"),
SettingsEntry("renderer", SettingsCategory.SESSIONS, "Desktop renderer", "Choose the Linux desktop renderer", listOf("desktop graphics"), SessionService.MODE_DESKTOP),
SettingsEntry("decky", SettingsCategory.SESSIONS, "Decky Loader", "Install and manage Decky Loader", listOf("decky loader", "plugin loader"), "steam"),
SettingsEntry("decky-enabled", SettingsCategory.SESSIONS, "Enable Decky Loader", "Start or stop Decky Loader with Steam", listOf("decky toggle"), "steam"),
SettingsEntry("decky-loader", SettingsCategory.SESSIONS, "Decky installation", "Install or repair the Decky loader", listOf("decky install", "decky status"), "steam"),
SettingsEntry("decky-plugins", SettingsCategory.SESSIONS, "Decky plugins", "Install a Decky plugin ZIP", listOf("plugin zip"), "steam"),
SettingsEntry("components", SettingsCategory.COMPATIBILITY, "Compatibility components", "Manage FEX, DXVK, and VKD3D-Proton", listOf("dxvk", "vkd3d", "fex")),
SettingsEntry("default-proton", SettingsCategory.COMPATIBILITY, "Default Proton", "Choose the synchronized Steam default", listOf("default-proton")),
SettingsEntry("editing-proton", SettingsCategory.COMPATIBILITY, "Editing components for", "Choose which Proton build to edit", listOf("component editing target")),
SettingsEntry("protons", SettingsCategory.COMPATIBILITY, "Proton versions", "Install and manage Proton builds", listOf("wine", "proton-ge", "installed proton")),
SettingsEntry("performance", SettingsCategory.COMPATIBILITY, "Performance", "Tune CPU cores and graphics compatibility options", listOf("cpu", "cores", "zink", "fsync")),
SettingsEntry("performance:override", SettingsCategory.COMPATIBILITY, "Per-app core limits", "Override the Steam client CPU core selection", listOf("client core override", "cpu affinity")),
SettingsEntry("performance:clientCores", SettingsCategory.COMPATIBILITY, "Steam client CPU cores", "Choose CPU cores available to the Steam client", listOf("steam cores", "client affinity")),
SettingsEntry("performance:gameCores", SettingsCategory.COMPATIBILITY, "Game CPU cores", "Choose CPU cores available to games", listOf("game affinity")),
SettingsEntry("performance:glthread", SettingsCategory.COMPATIBILITY, "GL threading", "Change GL threading behavior", listOf("gl thread")),
SettingsEntry("performance:zink", SettingsCategory.COMPATIBILITY, "Zink", "Change Zink lazy pipeline behavior", listOf("lazy pipeline")),
SettingsEntry("performance:noglerror", SettingsCategory.COMPATIBILITY, "Ignore GL errors", "Change OpenGL error handling", listOf("opengl errors")),
SettingsEntry("performance:gsrealtime", SettingsCategory.COMPATIBILITY, "Gamescope real-time priority", "Set Gamescope scheduling priority", listOf("realtime scheduling")),
SettingsEntry("performance:gpuclock", SettingsCategory.COMPATIBILITY, "Pin GPU clock", "Set the GPU clock policy", listOf("gpu clock")),
SettingsEntry("performance:sysmem", SettingsCategory.COMPATIBILITY, "Turnip system memory", "Change Turnip system memory behavior", listOf("tu sysmem")),
SettingsEntry("performance:xalia", SettingsCategory.COMPATIBILITY, "Xalia", "Change Xalia input behavior", listOf("no xalia")),
SettingsEntry("performance:syncfallback", SettingsCategory.COMPATIBILITY, "Esync fallback", "Change synchronization fallback behavior", listOf("esync")),
SettingsEntry("performance:fsyncfirst", SettingsCategory.COMPATIBILITY, "Fsync first", "Try fsync before other synchronization backends", listOf("fsync")),
SettingsEntry("performance:fastsync", SettingsCategory.COMPATIBILITY, "Fast sync", "Enable fast synchronization", listOf("wine sync")),
SettingsEntry("performance:seccomp", SettingsCategory.COMPATIBILITY, "Proot seccomp", "Change the proot seccomp setting", listOf("syscall filtering")),
SettingsEntry("performance:fastpath", SettingsCategory.COMPATIBILITY, "Proot fast path", "Enable the proot fast path", listOf("fast path")),
SettingsEntry("performance:hostname", SettingsCategory.COMPATIBILITY, "Guest hostname", "Set the Linux guest hostname", listOf("computer name")),
SettingsEntry("fex", SettingsCategory.COMPATIBILITY, "FEX preset", "Choose CPU translation settings for games", listOf("translation", "x86"), "steam"),
SettingsEntry("syncBackend", SettingsCategory.COMPATIBILITY, "Synchronization backend", "Choose the game synchronization backend", listOf("ntsync", "fsync", "esync"), "steam"),
SettingsEntry("game-env", SettingsCategory.COMPATIBILITY, "Game environment", "Edit per-game environment options", listOf("environment variables", "launch options"), "steam"),
SettingsEntry("storageDiagnostics", SettingsCategory.SUPPORT, "Storage diagnostics", "Collect diagnostics for game storage", listOf("storage logs"), "steam"),
SettingsEntry("runtime", SettingsCategory.SUPPORT, "Linux runtime", "Install, update, or manage the Linux runtime", listOf("runtime install", "container")),
SettingsEntry("logs", SettingsCategory.SUPPORT, "Session logs", "Enable, share, or clear session logs", listOf("diagnostics", "debug logs")),
SettingsEntry("updates", SettingsCategory.SUPPORT, "DroidDeck updates", "View build information and release channel", listOf("version", "build")),
SettingsEntry("phantom-process", SettingsCategory.SUPPORT, "Child-process limit", "Diagnose Android's process limit that can block Steam", listOf("phantom process", "developer options", "wireless debugging", "adb")),
)
fun search(query: String): List<SettingsEntry> = entries.filter { it.matches(query) }
}
private data class CategoryCard(val category: SettingsCategory, val icon: ImageVector)
@Composable
fun SettingsHub(s: FrontEndState, a: FrontEndActions) {
var query by rememberSaveable { mutableStateOf("") }
val frontFocus = LocalFrontFocus.current
var hasFocus by remember { mutableStateOf(false) }
var lastItem by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(frontFocus) {
snapshotFlow { if (hasFocus) frontFocus?.last else null }.filterNotNull().collect { lastItem = it }
}
LaunchedEffect(Unit) {
if (frontFocus != null) focusWithinFrames({ hasFocus }) {
val id = lastItem
if (id != null && (frontFocus.attached[id] ?: 0) > 0) frontFocus.items.getValue(id)
else frontFocus.primary
}
}
DisposableEffect(frontFocus) {
frontFocus?.let { it.primaryAttached++ }
onDispose { frontFocus?.let { it.primaryAttached-- } }
}
val cards = listOf(
CategoryCard(SettingsCategory.GENERAL, Icons.Outlined.Home),
CategoryCard(SettingsCategory.DISPLAY, Icons.Outlined.DisplaySettings),
CategoryCard(SettingsCategory.CONTROLS, Icons.Outlined.Gamepad),
CategoryCard(SettingsCategory.AUDIO, Icons.Outlined.GraphicEq),
CategoryCard(SettingsCategory.LIBRARY, Icons.Outlined.FolderOpen),
CategoryCard(SettingsCategory.SESSIONS, Icons.Outlined.Settings),
CategoryCard(SettingsCategory.COMPATIBILITY, Icons.Outlined.Speed),
CategoryCard(SettingsCategory.SUPPORT, Icons.Outlined.HelpOutline),
)
Column(
modifier = Modifier.fillMaxSize().onFocusChanged { hasFocus = it.hasFocus }.focusGroup()
.verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PageHeader("Settings")
HubSearchField(query = query, onQuery = { query = it }, frontFocus = frontFocus)
if (!s.ready || s.removalPending || PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)) {
val (title, detail, target) = when {
s.removalPending -> Triple("Runtime needs attention", "Runtime removal needs to be completed before using the session.", "runtime")
!s.ready -> Triple("Linux runtime is not ready", "Install or repair the runtime to launch Steam.", "runtime")
else -> Triple("Android process limit may block Steam", "Review the child-process setting and available fixes.", "phantom-process")
}
SettingsAnchor(target) { ActionRow(title, detail, "Review") { a.onSettingsCategory(SettingsCategory.SUPPORT, target) } }
}
if (query.isBlank()) {
val columns = if (LocalNarrowPane.current) 1 else 2
cards.chunked(columns).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp)) {
row.forEach { card ->
SettingsCategoryCard(card, Modifier.weight(1f).paneItem("settings-category:${card.category.name}"), onClick = { a.onSettingsCategory(card.category, null) })
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
} else {
val results = SettingsCatalog.search(query)
if (results.isEmpty()) {
Note("No settings found for “${query.trim()}”.")
} else {
results.forEach { entry ->
SettingsSearchResult(entry, Modifier.paneItem("settings-search:${entry.target}")) { a.onSettingsCategory(entry.category, entry.target) }
}
}
}
}
}
@Composable
private fun HubSearchField(query: String, onQuery: (String) -> Unit, frontFocus: FrontFocus?) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val shape = RoundedCornerShape(14.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(colors.surface)
.border(1.dp, pal.line2, shape).padding(horizontal = 14.dp, vertical = 11.dp),
) {
Icon(Icons.Outlined.Search, contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
val onQueryLatest = rememberUpdatedState(onQuery)
AndroidView(
modifier = Modifier.weight(1f).height(30.dp)
.then(if (frontFocus == null) Modifier else Modifier.focusRequester(frontFocus.primary)),
factory = { context ->
EditText(context).apply {
isSingleLine = true
background = null
setPadding(0, 0, 0, 0)
setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 15f)
hint = "Search settings"
contentDescription = "Search settings"
// Keep the hub visible while typing on landscape handhelds.
imeOptions = EditorInfo.IME_ACTION_SEARCH or EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI
setOnFocusChangeListener { _, focused ->
if (focused) frontFocus?.last = FrontFocus.PRIMARY
}
setOnEditorActionListener { _, action, _ ->
if (action == EditorInfo.IME_ACTION_SEARCH) {
(context.getSystemService(InputMethodManager::class.java)).hideSoftInputFromWindow(windowToken, 0)
clearFocus()
true
} else false
}
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
onQueryLatest.value(s?.toString().orEmpty())
}
override fun afterTextChanged(s: Editable?) = Unit
})
}
},
update = { field ->
field.setTextColor(colors.onBackground.toArgb())
field.setHintTextColor(colors.onSurfaceVariant.toArgb())
if (field.text.toString() != query) {
field.setText(query)
field.setSelection(query.length)
}
},
)
if (query.isNotEmpty()) Text(
"Clear", color = pal.signal, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable { onQuery("") }.padding(4.dp),
)
}
}
@Composable
private fun SettingsCategoryCard(card: CategoryCard, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val hovered = source.collectIsHoveredAsState().value
val shape = RoundedCornerShape(14.dp)
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.fillMaxSize().clip(shape)
.background(if (focused || hovered) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(focused, shape, pal.signal, pal.line)
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(14.dp),
) {
Icon(card.icon, contentDescription = null, tint = if (focused || hovered) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
Text(card.category.title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun SettingsSearchResult(entry: SettingsEntry, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val hovered = source.collectIsHoveredAsState().value
val shape = RoundedCornerShape(12.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().clip(shape)
.background(if (focused || hovered) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(focused, shape, pal.signal, pal.line)
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 11.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(entry.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
Spacer(Modifier.width(12.dp))
Text(entry.category.title, fontSize = 12.sp, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@@ -0,0 +1,47 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.session.SessionService
internal val settingsSessionModes = listOf(
SessionService.MODE_STEAM to "Steam",
SessionService.MODE_DESKTOP to "Desktop",
)
/** A specialist search returns to the category control that opens that specialist. */
internal fun settingsCategoryTarget(target: String?): String? = when {
target == "default-proton" || target == "editing-proton" -> "components"
target?.startsWith("performance:") == true -> "performance"
else -> target
}
sealed interface SettingsDestination {
val key: String
data class Category(
val category: SettingsCategory,
val mode: String = "steam",
val target: String? = null,
) : SettingsDestination {
override val key = "settings:${category.name}:$mode:${target.orEmpty()}"
}
data class Components(val tab: String? = null, val target: String? = null) : SettingsDestination {
override val key = "components"
}
data object Protons : SettingsDestination { override val key = "protons" }
data class Performance(val target: String? = null) : SettingsDestination { override val key = "performance:${target.orEmpty()}" }
data object Mapping : SettingsDestination { override val key = "controller-mapping" }
}
data class SettingsHistory(val destinations: List<SettingsDestination> = emptyList()) {
val current: SettingsDestination? get() = destinations.lastOrNull()
fun open(destination: SettingsDestination): SettingsHistory =
if (destination == current) this else copy(destinations = destinations + destination)
fun replace(destination: SettingsDestination): SettingsHistory =
copy(destinations = destinations.dropLast(1) + destination)
fun back(): SettingsHistory = copy(destinations = destinations.dropLast(1))
}
@@ -10,6 +10,10 @@ import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.layout.offset
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
@@ -56,10 +60,12 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -95,6 +101,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
@@ -202,6 +209,77 @@ private fun BumperKey(label: String, description: String, onClick: () -> Unit) {
/** The narrowest a setting's value box gets; the session menu's sheet sets it lower. */
val LocalChipMinWidth = androidx.compose.runtime.compositionLocalOf { 150.dp }
/** Search destination for settings. A target names a [SettingsAnchor] id; null leaves focus alone. */
val LocalSettingsTarget = staticCompositionLocalOf<String?> { null }
/** Enables anchors only on searchable settings category pages. */
val LocalSettingsAnchorsEnabled = staticCompositionLocalOf { false }
internal val LocalSettingsAnchorId = staticCompositionLocalOf<String?> { null }
@Composable
internal fun Modifier.settingsTarget(id: String): Modifier =
if (LocalSettingsAnchorsEnabled.current) paneItem("setting:$id") else this
/** A non-stopping focus group that can reveal itself and request its first actionable child. */
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun SettingsAnchor(id: String, modifier: Modifier = Modifier.fillMaxWidth(), content: @Composable () -> Unit) {
val target = LocalSettingsTarget.current
val enabled = LocalSettingsAnchorsEnabled.current
if (!enabled) {
content()
return
}
val bringIntoView = remember { BringIntoViewRequester() }
val focusRequester = remember { FocusRequester() }
Column(
modifier = modifier
.bringIntoViewRequester(bringIntoView)
.focusRequester(focusRequester)
.focusGroup(),
) {
CompositionLocalProvider(LocalSettingsAnchorId provides id) { content() }
}
LaunchedEffect(target, id) {
if (target == id) {
androidx.compose.runtime.withFrameNanos { }
runCatching { bringIntoView.bringIntoView() }
androidx.compose.runtime.withFrameNanos { }
runCatching { focusRequester.requestFocus() }
}
}
}
/** An optional group that starts closed; a settings search opens it so its destination can focus. */
@Composable
fun SettingsAdvanced(
id: String,
title: String,
targets: Set<String> = emptySet(),
content: @Composable ColumnScope.() -> Unit,
) {
val target = LocalSettingsTarget.current
val expandsForTarget = target == id || target in targets
var expanded by rememberSaveable(id, target) { mutableStateOf(expandsForTarget) }
SettingsAnchor(id) {
SettingsGroup(title) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 5.dp),
) {
SecondaryButton(
stringResource(if (expanded) R.string.common_hide else R.string.common_show),
compact = true,
modifier = Modifier.settingsTarget(id),
onClick = { expanded = !expanded },
)
}
if (expanded) content()
}
}
}
class MenuHost {
var open by mutableStateOf<String?>(null)
}
@@ -319,7 +397,9 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret")
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
modifier = modifier.then(
LocalSettingsAnchorId.current?.let { Modifier.settingsTarget(it) } ?: Modifier,
)
.widthIn(min = LocalChipMinWidth.current)
.heightIn(min = 44.dp)
.clip(RoundedCornerShape(10.dp))
@@ -400,14 +480,19 @@ fun <T> ChoiceRow(
onPick: (T) -> Unit,
) {
val open = host.open == key
SettingsRow(label, hint, highlighted = open, hintLines = hintLines) {
Box {
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
options.forEachIndexed { index, (value, text) ->
MenuItem(text, checked = value == selected, focusRequester = if (index == 0) firstItemFocus else null) {
onPick(value)
host.open = null
SettingsAnchor(key) {
SettingsRow(label, hint, highlighted = open, hintLines = hintLines) {
Box {
ValueChip(
options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled,
modifier = chipModifier,
) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
options.forEachIndexed { index, (value, text) ->
MenuItem(text, checked = value == selected, focusRequester = if (index == 0) firstItemFocus else null) {
onPick(value)
host.open = null
}
}
}
}
@@ -416,10 +501,13 @@ fun <T> ChoiceRow(
}
@Composable
fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) =
SettingsRow(label, hint) {
ToggleSwitch(checked, enabled, label, chipModifier) { host.open = null; onChange(it) }
fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) {
SettingsAnchor(key) {
SettingsRow(label, hint) {
ToggleSwitch(checked, enabled, label, chipModifier.settingsTarget(key)) { host.open = null; onChange(it) }
}
}
}
/** An on/off switch: one tap or one A press flips it, where a menu of On and Off took three. */
@Composable
@@ -178,7 +178,7 @@ fun WirelessAdbFixPage(
step == 2 -> stringResource(R.string.wadb_title_done)
else -> stringResource(R.string.wadb_title)
},
eyebrow = stringResource(R.string.setup_title),
eyebrow = stringResource(R.string.settings_title),
lede = when {
compactSplit && step == 0 -> stringResource(R.string.wadb_lede_compact0)
compactSplit && step == 1 -> stringResource(R.string.wadb_lede_compact1)
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="settings_title">Settings</string>
</resources>
+5 -5
View File
@@ -203,10 +203,10 @@
<string name="add_app_folder_copied">Its folder holds Linux programs, which shared storage cannot run, so it is copied into DroidDeck (%1$s).</string>
<string name="add_app_confirm">Add</string>
<string name="add_app_cancel">Cancel</string>
<string name="add_app_runtime_required">Install the Linux runtime from Setup first.</string>
<string name="add_app_runtime_required">Install the Linux runtime from Settings → Support &amp; diagnostics first.</string>
<string name="add_app_busy">Wait for %1$s to finish.</string>
<string name="steam_compat_label">Compatible</string>
<string name="proton_compatible_note">Builds marked “%1$s” in Steam’s Compatibility menu have DroidDeck’s prefix fixes and start games here. The default is Proton Experimental ARM64 (%1$s), or Proton 11.0 ARM64 (%1$s) once that newer build is installed. Valve’s own Proton entries without the mark cannot start a game on this device.</string>
<string name="proton_compatible_note">In Steam’s Compatibility menu, choose a build marked “%1$s”. Unmarked Valve builds cannot launch games here.</string>
<string name="proton_installed_compatible">Installed %1$s · %2$s</string>
<string name="common_cancel">Cancel</string>
<string name="common_auto">Auto</string>
@@ -387,7 +387,7 @@
<string name="comp_applies_next_game">Applies the next time a game starts</string>
<string name="comp_import_zip">Import .zip</string>
<string name="comp_reading">Reading Protons…</string>
<string name="comp_no_proton">No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton or CachyOS from Setup.</string>
<string name="comp_no_proton">No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton or CachyOS from Settings → Compatibility &amp; performance → Proton versions.</string>
<string name="comp_original">Original · %1$s</string>
<string name="comp_this_build">%1$s · this build</string>
<string name="comp_earlier_build">%1$s · earlier build</string>
@@ -620,7 +620,7 @@
<string name="store_title">Store</string>
<string name="store_chip_flathub">Flathub · ARM64</string>
<string name="store_chip_ready">● Flatpak ready</string>
<string name="store_needs_runtime">Install the Linux runtime from Setup first.</string>
<string name="store_needs_runtime">Install the Linux runtime from Settings → Support &amp; diagnostics first.</string>
<string name="store_setup_title">Set up Flatpak</string>
<string name="store_setup_text">Adds Flatpak and connects it to Flathub (about 20 MB). The first app also downloads its runtime.</string>
<string name="store_setting_up">Setting up…</string>
@@ -840,7 +840,7 @@
<string name="content_runtime_required">Runtime required</string>
<string name="content_loading_details">Loading install details…</string>
<string name="content_details_unavailable">Install details are unavailable. Try again later.</string>
<string name="content_needs_runtime">Install the Linux runtime from Setup first.</string>
<string name="content_needs_runtime">Install the Linux runtime from Settings → Support &amp; diagnostics first.</string>
<string name="content_stop_first">Stop the running session to install desktop apps.</string>
<string name="content_install_into">Installs %1$s into the Linux desktop.</string>
<string name="store_busy_percent_short">%1$s · %2$d%%</string>
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SettingsCatalogTest {
private fun targets(query: String) = SettingsCatalog.search(query).map { it.target }.toSet()
@Test fun allEightCategoriesAreRepresentedAndEveryDestinationIsUnique() {
assertEquals(SettingsCategory.entries.toSet(), SettingsCatalog.entries.map { it.category }.toSet())
val targetIds = SettingsCatalog.entries.map { it.target }
assertEquals("Each search result must have one unambiguous route", targetIds.size, targetIds.toSet().size)
}
@Test fun searchTrimsAndMatchesAliasesWithoutReturningResultsForBlankQueries() {
assertTrue(SettingsCatalog.search(" ").isEmpty())
assertEquals(setOf("theme"), targets(" COLORS "))
assertTrue(targets("wireless debugging").contains("phantom-process"))
assertTrue(targets("anisotropic filtering").contains("textures-anisotropy"))
}
@Test fun protonDefaultsInstalledBuildsAndComponentNamesRouteToTheirOwnPages() {
assertEquals(setOf("default-proton"), targets("default proton"))
assertEquals(setOf("editing-proton"), targets("component editing target"))
assertEquals(setOf("protons"), targets("installed proton"))
assertTrue(targets("DXVK").contains("components"))
assertTrue(targets("VKD3D").contains("components"))
assertFalse(SettingsCatalog.entries.any { it.target == "steam-menu" || it.target == "open-qam" })
}
@Test fun routesPreserveDestinationCategoryAndMode() {
val resolution = SettingsCatalog.entries.single { it.target == "res" }
assertEquals(SettingsCategory.DISPLAY, resolution.category)
assertEquals("steam", resolution.mode)
val renderer = SettingsCatalog.entries.single { it.target == "renderer" }
assertEquals(SettingsCategory.SESSIONS, renderer.category)
assertEquals(com.droiddeck.launcher.session.SessionService.MODE_DESKTOP, renderer.mode)
val overlay = SettingsCatalog.entries.single { it.target == "mangoapp" }
assertEquals(SettingsCategory.DISPLAY, overlay.category)
assertEquals("steam", overlay.mode)
}
@Test fun commonSearchTermsRouteToTheirSettingsPages() {
assertEquals(setOf("res"), targets("resolution"))
assertEquals(setOf("gpu-drivers"), targets("driver"))
assertTrue(targets("Proton").contains("protons"))
assertTrue(targets("Proton").contains("components"))
assertTrue(targets("microphone").contains("mic"))
assertTrue(targets("ROM").contains("roms"))
val updates = SettingsCatalog.entries.filter { it.target == "updates" }.single()
assertEquals(SettingsCategory.SUPPORT, updates.category)
}
@Test fun controllerKeyboardButtonRemainsSearchableAsASetting() {
assertTrue(targets("keyboard button").contains("controller-keyboard"))
assertFalse(SettingsCatalog.entries.any { it.target == "keyboard" || it.target == "second-screen" })
}
}
@@ -0,0 +1,42 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SettingsHistoryTest {
@Test fun sessionTabsUseTheRuntimeModeIdentifiers() {
assertEquals(listOf("steam", "lxqt"), settingsSessionModes.map { it.first })
assertEquals(settingsSessionModes.last().first, SettingsCatalog.search("Desktop renderer").single().mode)
}
@Test fun specialistBackRestoresCategoryAndMode() {
val category = SettingsDestination.Category(SettingsCategory.SESSIONS, com.droiddeck.launcher.session.SessionService.MODE_DESKTOP, "renderer")
val history = SettingsHistory().open(category).open(SettingsDestination.Components("gpu"))
assertEquals(category, history.back().current)
assertNull(history.back().back().current)
}
@Test fun changingModeReplacesCurrentPage() {
val category = SettingsDestination.Category(SettingsCategory.DISPLAY)
val history = SettingsHistory().open(category).replace(category.copy(mode = com.droiddeck.launcher.session.SessionService.MODE_DESKTOP))
assertEquals(com.droiddeck.launcher.session.SessionService.MODE_DESKTOP, (history.current as SettingsDestination.Category).mode)
assertNull(history.back().current)
}
@Test fun repeatedDestinationsDoNotAddBackSteps() {
val destination = SettingsDestination.Performance()
val history = SettingsHistory().open(destination).open(destination)
assertEquals(1, history.destinations.size)
assertNull(history.back().back().current)
}
@Test fun specialistSearchesReturnToTheirCategoryLaunchControl() {
assertEquals("performance", settingsCategoryTarget("performance:gameCores"))
assertEquals("components", settingsCategoryTarget("default-proton"))
assertEquals("components", settingsCategoryTarget("editing-proton"))
assertEquals("controller-mapping", settingsCategoryTarget("controller-mapping"))
assertEquals("res", settingsCategoryTarget("res"))
assertNull(settingsCategoryTarget(null))
}
}
+94
View File
@@ -0,0 +1,94 @@
# Settings layout
DroidDeck configuration now lives in a searchable Settings hub. Updates remains a
separate launcher tab. The hub links to the existing managers and uses the existing
preference stores and callbacks; there is no preference migration.
## Where controls moved
| Previous location | New home | Reason |
| --- | --- | --- |
| Setup → Launcher | General | Appearance and Android Home behavior are launcher settings. |
| Steam/Desktop settings → Display and HDR | Display & graphics | Resolution, frame limits, scaling and real HDR output belong together. Resolution, HDR and frame limit keep their mode-specific scope; aspect ratio and scaling remain shared. |
| Setup → Session → Frame generation | Display & graphics | Frame generation is a graphics choice rather than a startup behavior. Existing import and availability checks remain. |
| Session menu → Effects and texture filtering | Display & graphics, with the session controls retained | Saved defaults are discoverable before a session starts. The session menu still applies its live effects using the same controls. Simulated HDR is labeled separately from HDR10 output. |
| Setup → Controller; Steam/Desktop settings → Touch controls | Controls & input | Controller appearance, mapping, rumble, touch behavior, Steam controller identity and Back action order have one configuration home. |
| Steam settings → Audio | Audio & microphone | Game audio and Steam-menu audio have different backends; naming the target prevents a toggle from appearing to affect both. |
| Steam settings → Storage and Added games; Setup tools → ROMs and Files | Library & storage | An additional Steam library differs from importing an existing game directory. Folder and executable selectors remain available alongside artwork controls. |
| Steam/Desktop settings → Session, Startup, Client, Network, Decky and Desktop renderer; Setup → Offline | Sessions | These options determine how sessions start, suspend and integrate with Android. Steam-only controls remain Steam-only. |
| Launcher rail → Components; Setup tools → Protons and Performance; Steam settings → Game settings | Compatibility & performance | GPU drivers, Proton builds, component swaps and execution tuning are related troubleshooting tools. Specialist pages keep their own operations and progress indicators. |
| Setup → Overview and logs; Steam settings → Storage diagnostics | Support & diagnostics | Runtime readiness, child-process repair, logging and storage diagnostics are actionable troubleshooting controls. |
| Launcher rail → Updates | Updates, unchanged | Releases, test builds, update channels and build information remain directly accessible. Support links to this tab. |
Advanced FEX, synchronization, environment, effects, texture and storage diagnostic
controls stay in expandable groups. Searching can open these groups automatically;
users can still collapse them.
## Control and workflow inventory against `main`
This inventory compares the six pre-hub screens on `main` with the category
content and specialist pages. It includes controls that appear only for a
supported device, Steam mode, an installed runtime, or an active session.
| Previous source | Controls and workflows in the old screen | Current home and retained workflow |
| --- | --- | --- |
| `FrontEndSetup.kt` | Launcher theme; Android home-app enablement and conditional default-home picker; launcher fullscreen and animation; Flathub visibility; controller layout/mapping controls; Back-button action order; offline-account toggle; frame-generation mode and Lossless Scaling import; session-log enable/share/clear; Files and ROM-folder tools; Proton and Performance shortcuts; system checks for GPU support, runtime readiness/update/removal, phantom-process limit and sign-in. | General keeps launcher appearance/home/store controls; Controls keeps controller settings and Back order; Sessions keeps offline mode; Display & graphics keeps frame generation/import; Library & storage keeps Files and ROM folders; Compatibility & performance opens Components, Proton and Performance managers; Support & diagnostics keeps GPU/runtime/process-limit checks, developer-options and wireless-ADB recovery, account status and log controls. The Updates action links to the existing Updates tab. The process-limit repair/detail buttons, runtime progress and enabled/disabled states remain conditional. |
| `ModeSettingsDialog.kt` | Per-mode resolution presets plus custom width/height entry, aspect ratio, FPS cap, scaler and sharpness, HDR10, GPU-driver shortcut, touch mode, Steam on-screen-controller mode and controller identity, Back order, suspend policy, automatic PiP and PiP shortcut, Steam startup, Decky check/install/update/uninstall/enable/plugin-ZIP workflows, Steam Deck mode and branch, MangoHud, Wi-Fi discovery with permission explanation/system-settings route, imported game folders and forget action, artwork, per-folder executable selection/import, game-storage location/import, storage diagnostics, FEX, synchronization backend, per-game environment, fill/16:9 and Desktop renderer. | Display & graphics owns resolution/custom dialog, ratio, FPS, scaling/sharpness, HDR10, fill/16:9 and MangoHud; Steam/Desktop selection still scopes mode-specific values while ratio/scaler defaults remain shared. Controls owns touch and Steam controller identity, with Back order alongside controller setup. Audio & microphone owns direct game audio, separately labeled Steam-menu audio and microphone. Library & storage owns game imports/executable choices/artwork and second-library selection/folder import; storage diagnostics is under Support. Sessions owns suspend, PiP auto-entry, startup, Decky management, Steam Deck mode/branch, Wi-Fi discovery and Desktop renderer. Compatibility & performance owns the GPU-driver shortcut plus FEX, sync backend and game environment in an expandable group. Steam-only, Desktop-only, permission-dependent and Decky/runtime/session-running gates remain in place. |
| `ComponentsPage.kt` | GPU-driver tabs and Android/Linux driver management; release refresh, download/import, selection/removal and bundled Android-driver restore; Proton/component selectors; per-Proton FEX, DXVK and VKD3D original/package status; next-game queued swaps and cancellation; catalog refresh/download; WCP import; restore-original and delete confirmations; in-use/game-running status and About dialog. | Compatibility & performance opens the existing Components manager. GPU search opens its GPU view directly. The manager retains its tabs, progress, confirmation dialogs, queued-operation state, per-Proton view and driver import/restore actions. |
| `ProtonPage.kt` | Available Proton build list; install, remove and cancel-queued operations; runtime-not-ready explanation; install/remove/session-running availability gates and progress. | Compatibility & performance → Proton versions opens the same manager; runtime requirements, queue cancellation and operation gates remain there. |
| `PerformanceDialog.kt` | Client CPU-core override and client-core selection; game-core selection; GL threading, Zink, GL-error handling and Gamescope real-time priority; GPU-clock pinning; Turnip system-memory behavior; Xalia; esync fallback, fsync-first and fast sync; proot seccomp and dependent fast path; guest-hostname validation/edit; phantom-process warning. | Compatibility & performance → Performance opens the existing specialist page. Core multi-selects, conditional controls, hostname validation and warning remain on that page. FEX and the game synchronization selector are also discoverable directly in the expandable Compatibility group; their specialist workflows remain intact. |
| `SessionOverlay.kt` | Live HUD/fill/scaling/sharpness/frame-generation controls; effects preset, CAS/strength, fake HDR, debanding/strength, brightness, contrast, gamma, saturation, FXAA, toon, CRT and NTSC; texture anisotropy and LOD bias; live component selection/queued-swap cancellation; touch/OSC and virtual-controller rumble/Steam/QAM/keyboard buttons; Back order; physical/Android keyboard launch; second-screen mode/display; Android app launch; suspend policy, ratio, FEX/game environment, log sharing, PiP entry/auto-entry, background and stop-session confirmation; Steam-menu and QAM shortcuts. | SessionOverlay retains live HUD, launch-image controls, menus, component swaps, device routing, app launch and session actions. Display & graphics now also edits saved screen-effects and texture-filtering defaults. Those preference-backed values are used at session/game launch; the drawer continues applying screen effects live and keeps texture choices in its own controls. Look presets that select a scaler update the shared scaler state. Keyboard display, second-screen routing, QAM/Steam actions, app/session actions and stop confirmation remain exclusive to the live drawer; only the virtual-controller keyboard-button preference is in Controls. |
**Loss found:** none in this source-to-destination inventory. The old Setup tool cards
and Updates entry have become category/search destinations, while the Components,
Proton and Performance operations remain in their specialist screens. Session-only
actions remain in the drawer rather than becoming persistent settings.
## Navigation and scope
- Back returns from a specialist manager to its category, then to the hub or the
contextual Steam/Desktop/game page that opened settings. Selecting another rail
item closes the settings history.
- Category and hub scroll state is retained when a manager opens over them. Search
targets have stable IDs and request focus on the corresponding actionable control.
- Steam/Desktop selectors expose existing mode-specific defaults. Shared values do
not become separate preferences when the selected mode changes.
- GPU shortcuts open the GPU view directly. Automatic/manual driver selection,
effective selections, imports, downloads, deletion and bundled restore are retained.
- **Default Proton** changes Steam's synchronized default through `ProtonDefault`.
**Editing components for** selects the build whose components are displayed and
does not change the default. Proton installations remain a separate manager;
per-game Proton overrides remain in Steam's Properties → Compatibility.
- Keyboard display, second-screen routing, Steam menu/QAM actions, session restart
and stop stay exclusively in the session menu. Controller settings can still
configure the presence of a keyboard button without opening the live keyboard.
- Runtime prerequisites, active-session restrictions, Android permissions,
confirmations, downloads, queued operations and legacy synchronization switches
retain their existing behavior.
## Feedback behind the changes
These are qualitative reports, some from earlier builds, rather than a claim that
every reported runtime issue remains in the current version:
- [Driver navigation](https://discord.com/channels/1552707793247539220/1552707794430459916/1555132174364315729)
exposed confusion about being sent to Components and changing its view with RB.
The driver shortcut now chooses the GPU view directly.
- [Proton selection](https://discord.com/channels/1552707793247539220/1552707794430459916/1555965564621562008)
highlighted several selection concepts. Separate default, installation and editing
controls make each operation explicit while retaining Steam synchronization.
- [Audio targets](https://discord.com/channels/1552707793247539220/1556195514322780160/1556272336393478195)
motivated separating game audio from Steam-menu audio labels.
- [Storage paths](https://discord.com/channels/1552707793247539220/1556195514322780160/1556224196395270225)
motivated putting library paths beside game imports and file access.
## Verification boundaries
Navigation history and catalog routing have JVM tests, alongside the existing
preference and workflow regression tests. The complete APK build uses
`DROIDDECK_BUILD_VARIANT=debug tools/build_local.sh` to include native assets.
Device acceptance still requires checking controller focus and Back restoration,
search scrolling, narrow layouts, permission dialogs and the session-menu boundary
on an Android handheld. No Android device was connected during this implementation;
successful compilation and unit tests do not establish on-device rendering or input.