mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat(ui): Components as one stack of layers with a fixed detail frame
This commit is contained in:
1 parent
68fbcd6f39
commit
0b7ba68e38
10 files changed
+912
-647
No files matched your search
@@ -46,7 +46,6 @@ import com.droiddeck.launcher.session.GameSaves
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.ui.ProtonPage
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
@@ -124,7 +123,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
|
||||
private var showComponents by mutableStateOf(false)
|
||||
private var focusComponentsContent by mutableStateOf(true)
|
||||
@@ -550,7 +548,6 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
showPerformance -> { { PerformanceHost() } }
|
||||
showProtons -> { { ProtonHost() } }
|
||||
showComponents -> { { ComponentsHost() } }
|
||||
showMapping -> { { MappingHost() } }
|
||||
else -> null
|
||||
@@ -569,7 +566,7 @@ class MainActivity : ComponentActivity() {
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
pageKey = if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
|
||||
appScale = appScale,
|
||||
@@ -692,7 +689,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(); showComponents = false; showMapping = false; showPerformance = true },
|
||||
onRoms = { showRoms = true },
|
||||
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
|
||||
onBrowseFiles = { dir ->
|
||||
@@ -727,7 +724,7 @@ class MainActivity : ComponentActivity() {
|
||||
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
|
||||
offline = OfflineMode.enabled(this)
|
||||
},
|
||||
onPageBack = { showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onLanguage = { tag ->
|
||||
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
|
||||
@@ -837,7 +834,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
|
||||
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
|
||||
),
|
||||
),
|
||||
@@ -957,14 +954,12 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
/** On the Components page the pad's LB / RB step through the stack's layers, wrapping around. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
|
||||
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
|
||||
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
|
||||
components.compComp = com.droiddeck.launcher.ui.layerStep(components.compComp, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1117,13 +1112,8 @@ class MainActivity : ComponentActivity() {
|
||||
}, "game-saves-action").start()
|
||||
}
|
||||
|
||||
private fun openProtons() {
|
||||
showPerformance = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
showProtons = true
|
||||
protons.refreshProtons()
|
||||
}
|
||||
/** The Protons DroidDeck installs are the Components page's Proton layer. */
|
||||
private fun openProtons() = openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.PROTON_TAB)
|
||||
|
||||
private fun refreshController() {
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
@@ -1144,11 +1134,17 @@ class MainActivity : ComponentActivity() {
|
||||
focusComponentsContent = focusContent
|
||||
if (tab != null) components.compComp = tab
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showMapping = false
|
||||
showComponents = true
|
||||
// The Proton layer's options for every Proton, as they are now.
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
forceSsbs = SessionPrefs.forceSsbs(this)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
components.refreshComponents(snapshotFirst = true)
|
||||
drivers.refreshDrivers()
|
||||
protons.refreshProtons()
|
||||
}
|
||||
|
||||
private fun importComponent(uri: Uri) {
|
||||
@@ -1200,23 +1196,31 @@ class MainActivity : ComponentActivity() {
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
),
|
||||
protons = com.droiddeck.launcher.ui.ProtonsState(
|
||||
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
|
||||
runtimeReady = ready && !busy, sessionRunning = SessionState.running,
|
||||
),
|
||||
protonActions = com.droiddeck.launcher.ui.ProtonsActions(
|
||||
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) },
|
||||
),
|
||||
protonOptions = {
|
||||
com.droiddeck.launcher.ui.ProtonOptions(
|
||||
syncBackend = SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback), fexPreset = fexPreset, forceSsbs = forceSsbs,
|
||||
onSyncBackend = { id ->
|
||||
SessionPrefs.setSyncBackend(this, id)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtonHost() {
|
||||
ProtonPage(
|
||||
rows = protons.protonRows,
|
||||
busyId = protons.protonBusyId,
|
||||
stage = protons.protonStage,
|
||||
percent = protons.protonPercent,
|
||||
runtimeReady = ready && !busy,
|
||||
sessionRunning = SessionState.running,
|
||||
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 },
|
||||
)
|
||||
// A Proton installed or removed changes the stack's list of Protons.
|
||||
androidx.compose.runtime.LaunchedEffect(protons.protonBusyId) { if (protons.protonBusyId == null) components.refreshComponents() }
|
||||
}
|
||||
|
||||
private fun refreshPackages() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -461,7 +461,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest
|
||||
// Performance opens from Play's sheet, and is Steam's.
|
||||
s.pageKey == "performance" -> "steam"
|
||||
s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
|
||||
s.pageKey == "controller-mapping" -> "setup"
|
||||
selected.startsWith("app:") -> "games"
|
||||
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
|
||||
else -> s.pageKey ?: selected
|
||||
|
||||
@@ -57,6 +57,10 @@ 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 androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
|
||||
class PairRow(
|
||||
@@ -119,155 +123,38 @@ class GpuDriversActions(
|
||||
)
|
||||
|
||||
/**
|
||||
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
|
||||
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
|
||||
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
|
||||
* two lists each on their own, and imports, are under Advanced.
|
||||
* Everything about the chip - SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the
|
||||
* drivers in use - gathered once, off the main thread, with a button that copies it for a bug
|
||||
* report. Stepped out of the Components page's device chip; [first] is where a pad starts.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
|
||||
internal fun DeviceDetails(first: FocusRequester) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var showAll by rememberSaveable { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
DeviceCard(s)
|
||||
if (!s.unsupported) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) }
|
||||
ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
|
||||
}
|
||||
if (s.auto) AutoCard(s)
|
||||
else {
|
||||
val shown = s.pairs.filter { it.suits || showAll }
|
||||
if (shown.isEmpty()) Text(
|
||||
if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
|
||||
if (s.pairs.any { !it.suits }) FocusText(
|
||||
if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal,
|
||||
) { showAll = !showAll }
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.gpu_advanced)) {
|
||||
SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) {
|
||||
DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") }
|
||||
}
|
||||
SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) {
|
||||
DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
|
||||
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
|
||||
*/
|
||||
@Composable
|
||||
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
|
||||
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
|
||||
for (row in rows) DropdownMenuItem(
|
||||
leadingIcon = { Radio(row.id == selected) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
|
||||
)
|
||||
HorizontalDivider(color = pal.line)
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip -
|
||||
* SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once,
|
||||
* off the main thread, with a button that copies it for a bug report.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val tint = when {
|
||||
s.unsupported -> pal.error
|
||||
s.supported -> pal.good
|
||||
else -> AttentionAmber
|
||||
}
|
||||
var open by rememberSaveable { mutableStateOf(false) }
|
||||
var details by remember { mutableStateOf<List<DeviceInfo.Section>?>(null) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(open) {
|
||||
if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) }
|
||||
}
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val toggle = { open = !open; copied = false }
|
||||
LaunchedEffect(Unit) { details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } }
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clip(Shape14).background(colors.surface)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("gpu-device")
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
|
||||
.controllerConfirm(onClick = toggle)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
|
||||
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
|
||||
// needs: a long one (in some languages) would squeeze the name to a letter a line.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(
|
||||
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
|
||||
contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show),
|
||||
tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(open) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) {
|
||||
HorizontalDivider(color = pal.line)
|
||||
val sections = details
|
||||
if (sections == null) {
|
||||
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
} else {
|
||||
for (section in sections) DeviceInfoSection(section)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.device_info_copy), compact = true) {
|
||||
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
val sections = details
|
||||
if (sections == null) {
|
||||
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.device_info_copy), compact = true, modifier = Modifier.focusRequester(first)) {
|
||||
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
for (section in sections) DeviceInfoSection(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,115 +175,9 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) {
|
||||
Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
for ((label, value) in section.rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp))
|
||||
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(150.dp))
|
||||
Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What Auto has set and what it is doing. */
|
||||
@Composable
|
||||
private fun AutoCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val active = s.activePair
|
||||
val recommended = s.pairs.firstOrNull { it.recommended }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
Text(
|
||||
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin),
|
||||
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
)
|
||||
Text(
|
||||
if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair)
|
||||
else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
if (s.busy != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (s.percent.coerceAtLeast(0)) / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
// Selection is the radio; the ring is only ever focus.
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
|
||||
.clip(Shape14).background(colors.surface).background(fill)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Radio(selected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val usable = enabled && p.complete && !p.active
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
|
||||
.clip(Shape12).background(colors.surface).background(fill)
|
||||
.glideBorder(hot, Shape12, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(enabled = usable, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Radio(p.active)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (p.recommended) Text(
|
||||
stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
|
||||
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
when {
|
||||
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
|
||||
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
|
||||
}
|
||||
p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
!p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Radio(selected: Boolean) {
|
||||
val pal = LocalPalette.current
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
|
||||
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
|
||||
}
|
||||
}
|
||||
@@ -361,29 +361,6 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
onChange = a.onStorageDiagnostics,
|
||||
)
|
||||
}
|
||||
if (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(
|
||||
listOf(
|
||||
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
|
||||
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
|
||||
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
|
||||
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
|
||||
),
|
||||
s.syncBackend,
|
||||
) { 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,
|
||||
)
|
||||
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
|
||||
GameEnvironmentRow()
|
||||
}
|
||||
}
|
||||
if (tab == SheetTab.STEAM && steam) {
|
||||
SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
@@ -503,6 +480,36 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The options that apply to every Proton - how Wine syncs, FEX's preset, Force SSBS and the games'
|
||||
* environment - for the Components page's Proton layer.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProtonOptions(
|
||||
syncBackend: String, fexPreset: String, forceSsbs: Boolean,
|
||||
onSyncBackend: (String) -> Unit, onFexPreset: (String) -> Unit, onForceSsbs: (Boolean) -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
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),
|
||||
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
|
||||
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
|
||||
),
|
||||
syncBackend,
|
||||
) { id -> host.open = null; 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) }, fexPreset,
|
||||
note = stringResource(FexPreset.byId(fexPreset).detail), onPick = onFexPreset,
|
||||
)
|
||||
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), forceSsbs, onChange = onForceSsbs)
|
||||
GameEnvironmentRow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall for Decky Loader: are you sure steps out of it in red, Cancel first, so a stray A
|
||||
* costs nothing.
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean)
|
||||
|
||||
@Composable
|
||||
fun ProtonPage(
|
||||
rows: List<ProtonRow>,
|
||||
busyId: String?,
|
||||
stage: String?,
|
||||
percent: Int,
|
||||
runtimeReady: Boolean,
|
||||
sessionRunning: Boolean,
|
||||
onInstall: (String) -> Unit,
|
||||
onCancel: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
val colors = MaterialTheme.colorScheme
|
||||
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(
|
||||
stringResource(R.string.content_needs_runtime),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
if (sessionRunning) Text(
|
||||
stringResource(R.string.proton_stop_first),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
val compatible = stringResource(R.string.steam_compat_label)
|
||||
Text(
|
||||
stringResource(R.string.proton_compatible_note, compatible),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
SettingsGroup(stringResource(R.string.proton_available)) {
|
||||
for (row in rows) {
|
||||
SettingsRow(
|
||||
row.name,
|
||||
when {
|
||||
row.installed != null -> stringResource(R.string.proton_installed_compatible, row.installed, compatible)
|
||||
row.queued -> stringResource(R.string.proton_queued)
|
||||
else -> stringResource(R.string.proton_not_installed)
|
||||
},
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
when {
|
||||
row.installed != null -> SecondaryButton(stringResource(R.string.store_remove), enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) }
|
||||
busyId != null -> SecondaryButton(if (busyId == row.id) stringResource(R.string.store_installing) else stringResource(R.string.store_install), enabled = false) {}
|
||||
else -> SecondaryButton(stringResource(R.string.proton_install_now), enabled = runtimeReady && !sessionRunning) { onInstall(row.id) }
|
||||
}
|
||||
if (row.queued && row.installed == null && busyId == null) {
|
||||
SecondaryButton(stringResource(R.string.proton_cancel_queued), enabled = !sessionRunning) { onCancel(row.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (busyId == row.id) Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
|
||||
Text(
|
||||
if (stage != null && percent >= 0) stringResource(R.string.store_busy_percent_short, stage, percent) else stage ?: stringResource(R.string.store_starting),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
|
||||
)
|
||||
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,10 @@ internal fun StepOut(
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val hostWidth = constraints.maxWidth.toFloat()
|
||||
val hostHeight = constraints.maxHeight.toFloat()
|
||||
// No room under the control (a row near the foot of the screen): it steps out upward
|
||||
// instead, the same outline mirrored about the control.
|
||||
val up = measured?.let { pill.bottom + pull + it.height > hostHeight && pill.top - pull - it.height >= 0f } ?: false
|
||||
val room = if (side == StepSide.Left) pill.right else hostWidth - pill.left
|
||||
val maxW = with(density) { maxWidth.toPx() }.coerceAtMost(room).coerceAtLeast(pill.width)
|
||||
LaunchedEffect(open, measured) {
|
||||
@@ -246,6 +250,7 @@ internal fun StepOut(
|
||||
if (!grown) return@Canvas
|
||||
if (side == StepSide.Left) stepPath(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner)
|
||||
else stepPathRight(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner)
|
||||
if (up) path.transform(Matrix().apply { this[1, 1] = -1f; this[3, 1] = pill.top + pill.bottom })
|
||||
drawPath(path, lerp(hotFill, restFill, tint.value))
|
||||
drawPath(path, accent.copy(alpha = lerpF(0.9f, 0.55f, tint.value)), style = Stroke(rim))
|
||||
}
|
||||
@@ -258,7 +263,7 @@ internal fun StepOut(
|
||||
val x = if (side == StepSide.Left) pill.right - (measured?.width?.toFloat() ?: maxW) else pill.left
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(x.roundToInt(), (pill.bottom + pull).roundToInt()) }
|
||||
.offset { IntOffset(x.roundToInt(), (if (up) pill.top - pull - (measured?.height ?: 0) else pill.bottom + pull).roundToInt()) }
|
||||
.widthIn(min = with(density) { pill.width.toDp() }, max = with(density) { maxW.toDp() })
|
||||
.onSizeChanged { if (measured != it) measured = it }
|
||||
.controllerBack(onDismiss)
|
||||
|
||||
@@ -43,6 +43,8 @@ internal object Steps {
|
||||
@Composable
|
||||
internal fun StepsLayer(origin: Offset) {
|
||||
val ask = Steps.step ?: return
|
||||
// Back (B, or the system's) folds what is stepped out, before it reaches the page under it.
|
||||
androidx.activity.compose.BackHandler(enabled = Steps.open) { Steps.fold() }
|
||||
key(ask) {
|
||||
StepOut(
|
||||
open = Steps.open, pill = ask.anchor.translate(-origin), side = ask.side, accent = ask.accent,
|
||||
|
||||
@@ -2180,4 +2180,21 @@
|
||||
<string name="lip_fps">%1$d fps</string>
|
||||
<string name="lip_touch_off">Touch off</string>
|
||||
<string name="sheet_graphics_drivers">Graphics drivers</string>
|
||||
<!-- Components as one stack of layers. -->
|
||||
<string name="comp_this_proton">This Proton</string>
|
||||
<string name="comp_every_proton">Every Proton</string>
|
||||
<string name="comp_for_every_proton">For every Proton</string>
|
||||
<string name="comp_available_count">Available · %1$d</string>
|
||||
<string name="comp_checked">Checked %1$s</string>
|
||||
<string name="comp_check_all">Check for new components and drivers</string>
|
||||
<string name="comp_get">Get</string>
|
||||
<string name="comp_proton_bar">%1$s · DXVK, VKD3D-Proton and FEX below are this Proton\'s</string>
|
||||
<string name="comp_device_tested">%1$s · tested</string>
|
||||
<string name="comp_device_untested">%1$s · untested</string>
|
||||
<string name="comp_device_unsupported">%1$s · unsupported</string>
|
||||
<string name="gpu_auto_keeps">Keeps the recommended pair current</string>
|
||||
<string name="gpu_bar_auto">Auto · %1$s</string>
|
||||
<string name="gpu_separately">Runtime and display drivers separately</string>
|
||||
<string name="gpu_use_title">Use %1$s?</string>
|
||||
<string name="proton_remove_title">Remove %1$s?</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LayerStepTest {
|
||||
@Test fun theStackRunsFromTheProtonDownToTheGpuDrivers() {
|
||||
assertEquals(listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB), LAYERS)
|
||||
}
|
||||
|
||||
@Test fun rbStepsDownAndWraps() {
|
||||
assertEquals("dxvk", layerStep(PROTON_TAB, 1))
|
||||
assertEquals(PROTON_TAB, layerStep(GPU_TAB, 1))
|
||||
}
|
||||
|
||||
@Test fun lbStepsUpAndWraps() {
|
||||
assertEquals("fex", layerStep(GPU_TAB, -1))
|
||||
assertEquals(GPU_TAB, layerStep(PROTON_TAB, -1))
|
||||
}
|
||||
|
||||
@Test fun anUnknownLayerStartsFromTheTop() {
|
||||
assertEquals("dxvk", layerStep("tab-from-an-older-build", 1))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user