Desktop & apps is a page with a dropdown per tier, not a dialog

The package list opens in the front end's pane like the Settings pages. Each tier (Native ARM64,
ARM64 experimental, source builds) is a dropdown row with the rail's caret and an installed/total
pill; Native ARM64 starts open. Each package has its own Install/Remove button, and the one that is
installing shows its own step line and bar in its row (its tier opens when the install starts).
Leaving the page does not stop an install. The rail's Install emulators and Setup's Desktop & apps
highlight while the page is open.
This commit is contained in:
The412Banner committed 2026-09-23 13:04:02 -04:00
1 parent 0ea4b584b4
commit bde8dc0089
3 files changed
+186 -79

No files matched your search

@@ -28,7 +28,7 @@ import com.steamdeck.launcher.runtime.LinuxRuntime
import com.steamdeck.launcher.runtime.DesktopCatalog
import com.steamdeck.launcher.runtime.LinuxRuntimeInstaller
import com.steamdeck.launcher.session.SessionService
import com.steamdeck.launcher.ui.DesktopAppsDialog
import com.steamdeck.launcher.ui.DesktopAppsPage
import com.steamdeck.launcher.ui.PackageRow
import com.steamdeck.launcher.session.OfflineMode
import com.steamdeck.launcher.session.ProtonExtras
@@ -84,6 +84,7 @@ class MainActivity : ComponentActivity() {
private var showApps by mutableStateOf(false)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(emptyList())
private var packageRows by mutableStateOf<List<PackageRow>?>(emptyList())
private var pkgId by mutableStateOf<String?>(null)
private var pkgStage by mutableStateOf<String?>(null)
private var pkgPercent by mutableIntStateOf(-1)
private var desktopInstalled by mutableStateOf(false)
@@ -187,6 +188,7 @@ class MainActivity : ComponentActivity() {
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showApps -> { { AppsHost() } }
else -> null
}
FrontEndScreen(
@@ -199,7 +201,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else null,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showApps) "apps" else null,
theme = theme,
),
FrontEndActions(
@@ -235,7 +237,7 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
},
onProtons = { refreshProtons(); showProtons = true },
onPerformance = { refreshCores(); showPerformance = true },
onPerformance = { refreshCores(); showApps = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.steamdeck.launcher.files.FileManagerActivity::class.java)) },
onLogs = {
@@ -257,7 +259,7 @@ class MainActivity : ComponentActivity() {
},
onEmulatorHelp = { showEmulatorHelp = true },
onCredits = { showCredits = true },
onPageBack = { settingsMode = null; showPerformance = false },
onPageBack = { settingsMode = null; showPerformance = false; showApps = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
),
page = page,
@@ -271,13 +273,6 @@ class MainActivity : ComponentActivity() {
onClear = { SessionPrefs.setRomsDir(this, ""); romsDir = null; showRoms = false },
onDismiss = { showRoms = false },
)
if (showApps) DesktopAppsDialog(
rows = packageRows, busyStage = pkgStage, busyPercent = pkgPercent,
onInstall = { id -> installPackage(id) },
onRemove = { id -> catalog?.firstOrNull { it.id == id }?.let { DesktopCatalog.remove(this, it) }; refreshPackages() },
onLaunch = { path -> showApps = false; launchProgram(path) },
onDismiss = { showApps = false },
)
if (showProtons) ProtonDialog(
rows = protonRows,
onInstall = { id -> ProtonExtras.tools.first { it.id == id }.let { ProtonExtras.queue(this, it) }; refreshProtons() },
@@ -334,7 +329,21 @@ class MainActivity : ComponentActivity() {
if (!busy) Thread({ checkCatalog() }, "catalog").start()
}
/** Desktop & apps, in the front end's pane. */
@Composable
private fun AppsHost() {
DesktopAppsPage(
rows = packageRows, busyId = pkgId, busyStage = pkgStage, busyPercent = pkgPercent,
onInstall = { id -> installPackage(id) },
onRemove = { id -> catalog?.firstOrNull { it.id == id }?.let { DesktopCatalog.remove(this, it) }; refreshPackages() },
onLaunch = { path -> showApps = false; launchProgram(path) },
onBack = { showApps = false },
)
}
private fun openApps() {
settingsMode = null
showPerformance = false
showApps = true
if (catalog.isNullOrEmpty()) Thread({
val fetched = DesktopCatalog.fetch()
@@ -385,13 +394,13 @@ class MainActivity : ComponentActivity() {
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (pkgStage != null) return
pkgStage = "Starting…"; pkgPercent = -1
pkgId = id; pkgStage = "Starting…"; pkgPercent = -1
Thread({
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, "${entry.name}: $problem", android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
}
@@ -518,6 +527,7 @@ class MainActivity : ComponentActivity() {
}
private fun openModeSettings(mode: String) {
showApps = false
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
@@ -1,23 +1,48 @@
package com.steamdeck.launcher.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.draw.drawWithContent
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
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
class PackageRow(
val id: String, val name: String, val tier: Int, val version: String, val size: String,
@@ -25,72 +50,144 @@ class PackageRow(
val launchers: List<Pair<String, String>> = emptyList(),
)
private val RowShape = RoundedCornerShape(10.dp)
private fun tierName(tier: Int) = when (tier) {
1 -> "Native ARM64"
2 -> "ARM64 · experimental"
else -> "Source build · experimental"
}
/**
* Desktop & apps as a page in the front end's pane: one dropdown per tier, opened and closed like
* the rail's own groups, and each package with its own Install/Remove and, while it installs, its
* own bar. Leaving the page does not stop an install; coming back shows where it is.
*/
@Composable
fun DesktopAppsDialog(
fun DesktopAppsPage(
rows: List<PackageRow>?,
busyId: String?,
busyStage: String?,
busyPercent: Int,
onInstall: (String) -> Unit,
onRemove: (String) -> Unit,
onLaunch: (path: String) -> Unit,
onDismiss: () -> Unit,
onBack: () -> Unit,
) {
AlertDialog(
onDismissRequest = { if (busyStage == null) onDismiss() },
title = { Text("Desktop & apps") },
text = {
Column(modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) {
if (busyStage != null) {
Text(if (busyPercent >= 0) "$busyStage $busyPercent%" else busyStage, style = MaterialTheme.typography.bodySmall)
if (busyPercent >= 0) LinearProgressIndicator(progress = { busyPercent / 100f }, modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
Spacer(Modifier.height(8.dp))
}
when {
rows == null -> Text("Could not reach the package catalog", color = MaterialTheme.colorScheme.onSurfaceVariant)
rows.isEmpty() -> Text("Loading the catalog…", color = MaterialTheme.colorScheme.onSurfaceVariant)
else -> Unit
}
var lastTier = 0
for (row in rows ?: emptyList()) {
if (row.tier != lastTier) {
lastTier = row.tier
Text(
when (row.tier) {
1 -> "Native ARM64"
2 -> "ARM64 · experimental"
else -> "Source build · experimental"
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp, bottom = 2.dp),
)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(row.name, style = MaterialTheme.typography.bodyMedium)
Text(
(if (row.installed != null) "Installed ${row.installed}" else "${row.version} · ${row.size}") +
(if (row.notes.isNotEmpty()) " · ${row.notes}" else ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (row.installed != null) TextButton(enabled = busyStage == null, onClick = { onRemove(row.id) }) { Text("Remove") }
else TextButton(enabled = busyStage == null, onClick = { onInstall(row.id) }) { Text("Install") }
}
if (row.installed != null && row.launchers.isNotEmpty()) {
Row(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, bottom = 2.dp)) {
for ((label, path) in row.launchers) {
TextButton(enabled = busyStage == null, onClick = { onLaunch(path) }) {
Text(" $label", style = MaterialTheme.typography.labelMedium)
}
}
}
}
val host = rememberMenuHost()
val colors = MaterialTheme.colorScheme
// The first tier starts open; the rest wait for a tap.
var open by remember { mutableStateOf(setOf(1)) }
// An install always shows: its tier opens when it starts.
val busyTier = rows?.firstOrNull { it.id == busyId }?.tier
LaunchedEffect(busyTier) { if (busyTier != null) open = open + busyTier }
SettingsPage(
host, title = "Desktop & apps", eyebrow = "Desktop",
lede = "Packages for the Linux desktop. Each installs into the runtime and appears in the desktop's menu.",
onBack = onBack,
) {
when {
rows == null -> Text("Could not reach the package catalog.", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp))
rows.isEmpty() -> Text("Loading the catalog…", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp))
}
for ((tier, group) in (rows ?: emptyList()).groupBy { it.tier }.toSortedMap()) {
val expanded = tier in open
TierHeader(tierName(tier), "${group.count { it.installed != null }}/${group.size}", expanded) {
open = if (expanded) open - tier else open + tier
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(Motion.tw(280)) + fadeIn(Motion.tw(280)),
exit = shrinkVertically(Motion.tw(200)) + fadeOut(Motion.tw(160)),
) {
val line = LocalPalette.current.line
Column(
modifier = Modifier.padding(start = 16.dp).fillMaxWidth()
.drawWithContent { drawContent(); drawRect(line, size = size.copy(width = 1.dp.toPx())) }
.padding(start = 8.dp, top = 2.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
for (row in group) PackageItem(
row, busy = busyId == row.id, anyBusy = busyId != null,
stage = busyStage, percent = busyPercent,
onInstall = onInstall, onRemove = onRemove, onLaunch = onLaunch,
)
}
}
},
confirmButton = { TextButton(enabled = busyStage == null, onClick = onDismiss) { Text("Close") } },
)
}
}
}
/** A tier's dropdown row: the rail's caret, label and count pill. */
@Composable
private fun TierHeader(label: String, count: String, expanded: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "tierRing")
val rot by animateFloatAsState(if (expanded) 90f else 0f, Motion.sp(0.6f), label = "tierCaret")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
.clip(RowShape)
.background(if (hovered) Color.White.copy(alpha = 0.04f) else Color.Transparent)
.border(1.5.dp, ring, RowShape)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.padding(horizontal = 10.dp, vertical = 9.dp),
) {
Text("›", fontSize = 16.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(14.dp).rotate(rot))
Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, modifier = Modifier.weight(1f))
Text(
count, fontSize = 11.sp, color = colors.onSurfaceVariant,
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(colors.background).padding(horizontal = 7.dp, vertical = 2.dp),
)
}
}
/** One package inside its tier: name, size or installed version, notes, the button, and its own bar. */
@Composable
private fun PackageItem(
row: PackageRow, busy: Boolean, anyBusy: Boolean, stage: String?, percent: Int,
onInstall: (String) -> Unit, onRemove: (String) -> Unit, onLaunch: (String) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val bg by animateColorAsState(if (busy) pal.signal.copy(alpha = 0.10f) else Color.Transparent, Motion.tw(200), label = "pkgBg")
Column(modifier = Modifier.fillMaxWidth().clip(RowShape).background(bg).padding(horizontal = 10.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
Text(
if (row.installed != null) " ● ${row.installed}" else " ${row.size}",
fontSize = 11.sp, color = if (row.installed != null) pal.good else colors.onSurfaceVariant, maxLines = 1,
)
}
if (row.notes.isNotEmpty()) Text(row.notes, fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
if (row.installed != null) SecondaryButton("Remove", enabled = !anyBusy) { onRemove(row.id) }
else SecondaryButton(if (busy) "Installing…" else "Install", enabled = !anyBusy) { onInstall(row.id) }
}
if (busy) {
Text(
if (stage != null && percent >= 0) "$stage · $percent%" else stage ?: "Starting…",
fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp, bottom = 4.dp),
)
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
if (row.installed != null && row.launchers.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) {
for ((label, path) in row.launchers) {
Box(
modifier = Modifier.clip(RoundedCornerShape(99.dp)).border(1.dp, pal.line2, RoundedCornerShape(99.dp))
.clickable(enabled = !anyBusy) { onLaunch(path) }.padding(horizontal = 10.dp, vertical = 4.dp),
) { Text("▶ $label", fontSize = 12.sp, color = colors.onBackground) }
}
}
}
}
}
@@ -331,7 +331,7 @@ private fun Rail(
) { if (e.id != "retroarch") a.onRoms() }
}
}
if (s.emulators.none { it.installed }) NavItem("Install emulators", "x", false, small = true, muted = true, register = register, unregister = unregister) { a.onApps() }
if (s.emulators.none { it.installed }) NavItem("Install emulators", "apps", selected == "apps", small = true, muted = true, register = register, unregister = unregister) { a.onApps() }
NavItem("Settings", "settings:lxqt", selected == "settings:lxqt", small = true, tiny = true, muted = true, i = s.emulators.count { it.installed }, register = register, unregister = unregister) { a.onDesktopSettings() }
}
Spacer(Modifier.height(6.dp))
@@ -342,7 +342,7 @@ private fun Rail(
NavItem("Setup", "x", false, caret = openSetup, count = 10, onClick = onToggleSetup)
Sub(openSetup) {
item("Files", null, 0, null, a.onFiles)
item("Desktop & apps", null, 1, null, a.onApps)
item("Desktop & apps", null, 1, "apps", a.onApps)
item("Compatibility tools", null, 2, null, a.onProtons)
Box {
item("Frame generation", s.frameGenLabel, 3, null) { menus.open = "fg" }