feat(ui): Components as one stack of layers with a fixed detail frame

This commit is contained in:
xXJsonDeruloXx committed 2026-10-10 18:48:33 -04:00
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,
+17
View File
@@ -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))
}
}