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" }