From bde8dc0089ab5a7dd9eb28cddec4d16704693bbd Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 13:04:02 -0400 Subject: [PATCH] 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. --- .../com/steamdeck/launcher/MainActivity.kt | 36 ++- .../launcher/ui/DesktopAppsDialog.kt | 225 +++++++++++++----- .../steamdeck/launcher/ui/FrontEndScreen.kt | 4 +- 3 files changed, 186 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 499e76642..74cfd98f0 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -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?>(emptyList()) private var packageRows by mutableStateOf?>(emptyList()) + private var pkgId by mutableStateOf(null) private var pkgStage by mutableStateOf(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) diff --git a/app/src/main/java/com/steamdeck/launcher/ui/DesktopAppsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/DesktopAppsDialog.kt index 74a2834e2..7d0ff452a 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/DesktopAppsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/DesktopAppsDialog.kt @@ -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> = 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?, + 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) } + } + } + } + } } diff --git a/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt index 58277e848..5ffeadc2b 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt @@ -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" }