mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-15 03:37:09 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c197a017e6 | ||
|
|
15e8a57fc0 | ||
|
|
cd4f02d0b0 | ||
|
|
bf14487872 | ||
|
|
8035963559 | ||
|
|
cf556596f0 | ||
|
|
7f1a743733 | ||
|
|
6a9c5da0d0 |
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)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="settings_title">Settings</string>
|
||||
</resources>
|
||||
@@ -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 & 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 & 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 & 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 & 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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user