From bb4aee702734179e1685961703be8345efbefab5 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 11:38:42 -0400 Subject: [PATCH] feat(ui): Desktop in the Steam tab's shape, with its own lifted sheet --- .../launcher/runtime/DesktopCatalog.kt | 3 + .../launcher/runtime/DesktopRemoval.kt | 8 + .../com/droiddeck/launcher/ui/DesktopPage.kt | 445 ++++++++++++++++++ .../com/droiddeck/launcher/ui/FrontEndArt.kt | 13 + .../droiddeck/launcher/ui/FrontEndContent.kt | 307 ++++-------- .../droiddeck/launcher/ui/FrontEndScreen.kt | 16 +- .../java/com/droiddeck/launcher/ui/Hop.kt | 4 + .../com/droiddeck/launcher/ui/LiftSheet.kt | 32 +- .../com/droiddeck/launcher/ui/PageFlood.kt | 9 +- .../com/droiddeck/launcher/ui/SteamHome.kt | 2 +- .../com/droiddeck/launcher/ui/StorePage.kt | 9 +- app/src/main/res/values/strings.xml | 7 + 12 files changed, 625 insertions(+), 230 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index f24b267cd..971608106 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -110,6 +110,9 @@ object DesktopCatalog { return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists() } + /** The installed desktop's size on disk; off the main thread. */ + fun desktopSize(context: Context): Long = DesktopRemoval.installedSize(context) + fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) = DesktopRemoval.cleanLegacy(context, listener) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt index 3f3b493e5..09c93d73d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt @@ -19,6 +19,14 @@ internal object DesktopRemoval { private fun protected(context: Context, root: File) = DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip") + /** + * What the desktop's own files take on disk, from the same inventory its removal uses (each + * file's size); the bundled apps' and emulators' shared files are not counted. Reads an asset: + * call it off the main thread. + */ + fun installedSize(context: Context): Long = + lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L } + /** Called before a guest starts or just after KDE extraction; retries after a process death. */ fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) { val root = LinuxRuntime.rootDir(context) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt new file mode 100644 index 000000000..c7011d0eb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt @@ -0,0 +1,445 @@ +package com.droiddeck.launcher.ui + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.DesktopWindows +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.runtime.UserApps +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.store.UserAppsState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// The Desktop tab in the Steam tab's shape: a tilted wall of little windows behind the word and +// one big Open desktop, with Play's lip and lifted sheet under it (Display, Controls, Session and +// Files). Under the hero the page scrolls to two shelves - the apps that run in the desktop, and +// what can be added - and the hero folds into a slim header as it goes. + +/** The desktop's own chips: its session settings, then Files. */ +private val DESKTOP_TABS = modeSheetTabs(steam = false) + SheetTab.FILES + +@Composable +internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, onSelect: (String) -> Unit) { + val ctx = LocalContext.current + val density = LocalDensity.current + val narrow = LocalNarrowPane.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + LaunchedEffect(Unit) { UserAppsState.refresh(ctx) } + val installed = s.emulators.filter { it.installed } + val available = s.emulators.filter { !it.installed } + val apps = UserAppsState.items + var adding by rememberSaveable { mutableStateOf(false) } + var sheetOpen by remember { mutableStateOf(false) } + val scroll = rememberScrollState() + val scope = androidx.compose.runtime.rememberCoroutineScope() + // Where the Your apps shelf ends, for a new emulator's dot to land. + var yourAppsAt by remember { mutableStateOf(null) } + var lastCardAt by remember { mutableStateOf(null) } + val mode = SessionService.MODE_DESKTOP + val tabs = DESKTOP_TABS.map { it to sheetTabLabel(it) } + val open = if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop) + // Opened from a card: the page floods out of it, its picture on the tile. + fun openFrom(key: String, at: Rect?, art: Any?, page: String) { + at?.let { PageOrigin.mark(Origin(it, 16f * density.density, art = art)) } + onSelect(page) + } + + BoxWithConstraints(modifier.fillMaxSize().clipToBounds()) { + val paneH = maxHeight + Column(Modifier.fillMaxSize().verticalScroll(scroll, enabled = !sheetOpen)) { + // The wall stays inside the hero; the shelves sit on the page's own ground. + Box(Modifier.fillMaxWidth().height(paneH).clipToBounds()) { + LiftHero( + key = "desktop", tabs = tabs, lip = { lipFor(ctx, mode, full = false) }, + onOpen = { scope.launch { scroll.animateScrollTo(0) }; a.onDesktopSettings() }, + onPrimary = a.onDesktop, downOpens = false, onOpenChange = { sheetOpen = it }, + start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, + background = { + DesktopWall(installed.map { it.iconRes } + apps.mapNotNull { it.icon }) + WallFade() + }, + words = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + // The word at the wordmark's size. + Text( + stringResource(R.string.rail_desktop), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp, + fontWeight = FontWeight.Black, color = colors.onBackground, + ) + Chip(stringResource(R.string.desktop_shell_kde), ok = true) + } + BusyChip(s) + } + }, + button = { click -> + // Enabled without a runtime or the desktop: the session's loading screen installs them first. + PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click) + }, + rows = { tab -> + if (tab == SheetTab.FILES) DesktopFiles(s, a) + else a.modeSheet(mode, tab, SheetExtras()) + }, + ) + } + Column(Modifier.fillMaxWidth().padding(start = if (narrow) 16.dp else 22.dp, end = if (narrow) 16.dp else 22.dp, bottom = 24.dp)) { + // ---- Your apps: the emulators installed, and the apps added ---- + ShelfTitle(stringResource(R.string.user_apps_section), (installed.size + apps.size).takeIf { it > 0 }?.toString()) + val seen = remember { mutableSetOf() } + val mounted = remember { booleanArrayOf(false) } + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().onGloballyPositioned { yourAppsAt = it.boundsInRoot() }.horizontalScroll(rememberScrollState()).padding(vertical = 2.dp), + ) { + val keys = installed.map { "emu:${it.id}" } + apps.map { "user:${it.key}" } + keys.forEachIndexed { i, k -> + key(k) { + // A card that arrives after the shelf is up (an emulator just installed) opens in. + val state = remember { MutableTransitionState(!mounted[0] || k in seen) }.apply { targetState = true } + LaunchedEffect(Unit) { seen += k } + AnimatedVisibility(state, enter = expandHorizontally(Motion.sp(0.75f)) + fadeIn(Motion.tw(300, 80))) { + val last = Modifier.then(if (i == keys.lastIndex) Modifier.onGloballyPositioned { lastCardAt = it.boundsInRoot() } else Modifier) + Box(last) { + val e = installed.firstOrNull { "emu:${it.id}" == k } + val app = apps.firstOrNull { "user:${it.key}" == k } + if (e != null) { + val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() } + DesktopCard( + k, e.name, + if (e.id == "retroarch") system else "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size), + art = e.iconRes, first = i == 0, + ) { at -> openFrom(k, at, e.iconRes, k) } + } else if (app != null) DesktopCard(k, app.name, stringResource(app.kind.label()), art = app.icon, fallback = if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, first = i == 0) { at -> + openFrom(k, at, app.icon, k) + } + } + } + } + } + if (keys.isEmpty()) Text(stringResource(R.string.desktop_no_apps), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp)) + } + LaunchedEffect(Unit) { mounted[0] = true } + if (UserAppsState.working != null || UserAppsState.lastError != null) Box(Modifier.padding(top = 8.dp)) { UserAppsProgressLine() } + + // ---- Get more: the emulators not installed, adding an app, and Flathub ---- + ShelfTitle(stringResource(R.string.desktop_get_more), null) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 2.dp)) { + available.forEach { e -> + key(e.id) { + val pkgId = Library.packageId(e.id) + val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } } + val busyHere = pkg != null && s.packageBusyId == pkg.id + val canGet = pkg != null && !s.busy && s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning + val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() } + DesktopCard( + "emu:${e.id}", e.name, if (busyHere) (s.packageStage ?: stringResource(R.string.store_installing)) else system, + art = e.iconRes, badge = if (busyHere) null else stringResource(R.string.comp_get), + progress = if (busyHere) s.packagePercent else null, + ) { at -> + // Get installs it there and then, and a dot flies to where its card will be; + // when it cannot (no runtime, a session running), its page says why. + if (canGet && pkg != null && at != null) { + a.onInstallPackage(pkg.id) + val shelf = yourAppsAt + val endAt = lastCardAt + Flights.fly(Flight(at, to = { + val r = shelf ?: return@Flight null + Offset(minOf((endAt?.right ?: r.left) + 70.dp.toPx(), r.right - 20.dp.toPx()), r.center.y) + })) + } else openFrom("emu:${e.id}", at, e.iconRes, "emu:${e.id}") + } + } + } + DesktopCard("add", stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), art = null, fallback = Icons.Filled.Add, accent = true) { if (!s.busy) adding = true } + if (s.storeEnabled) { + val back = stringResource(R.string.rail_desktop) + val show = LocalShowSection.current + DesktopCard("flathub", stringResource(R.string.flathub_name), stringResource(R.string.flathub_line), art = null, fallback = Icons.Outlined.Storefront, tint = FlathubTeal) { at -> + // Flathub is its own section: the card hops there, and B on it comes back here. + if (at == null) show("store") + else Hops.go(HopOrigin("desktop", HOP_SURFACE_DESKTOP, null, "tile:desktop:flathub", at, back, dest = "store", page = "store")) { show("store") } + } + } + } + } + } + // Scrolled past the hero: it folds into a slim header with the word and a compact Open desktop. + val collapsed = with(density) { scroll.value > (paneH - 48.dp).toPx() } + AnimatedVisibility( + collapsed, + enter = fadeIn(Motion.tw(200)) + slideInVertically(Motion.tw(240)) { -it }, + exit = fadeOut(Motion.tw(160)) + slideOutVertically(Motion.tw(200)) { -it }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().height(48.dp).background(colors.background.copy(alpha = 0.96f)).padding(horizontal = if (narrow) 16.dp else 22.dp), + ) { + Text(stringResource(R.string.rail_desktop), fontSize = 20.sp, fontWeight = FontWeight.Black, color = colors.onBackground) + Spacer(Modifier.weight(1f)) + PrimaryButton(open, enabled = !s.busy, compact = true, onClick = a.onDesktop) + } + } + } + if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) } +} + +/** A shelf's heading, with a count beside it. */ +@Composable +private fun ShelfTitle(title: String, count: String?) { + val colors = MaterialTheme.colorScheme + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 18.dp, bottom = 8.dp)) { + Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + if (count != null) Text(count, fontSize = 13.sp, color = colors.onSurfaceVariant) + } +} + +/** + * The hero's wall: little windows tilted and drifting as Steam's capsules do, each a window with a + * bar along its foot and an emulator's or app's icon inside; blank windows when there are none. + */ +@Composable +private fun DesktopWall(icons: List) { + val pal = LocalPalette.current + val shape = RoundedCornerShape(8.dp) + TiltedWall(icons.size, if (icons.isEmpty()) 75_000 else 50_000, capW = 132.dp, capH = 84.dp, gap = 14.dp) { i, m -> + Box(m.clip(shape).background(Brush.linearGradient(listOf(pal.surfaceVariant, pal.surface))).border(1.dp, pal.line2, shape)) { + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(12.dp).background(pal.background.copy(alpha = 0.7f))) + val icon = icons.getOrNull(i) + Box(Modifier.fillMaxSize().padding(bottom = 12.dp), contentAlignment = Alignment.Center) { + when (icon) { + is Int -> Image(painterResource(icon), null, modifier = Modifier.size(34.dp)) + null -> {} + else -> AsyncImage(model = icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(34.dp)) + } + } + } + } +} + +/** + * One thing that runs in the desktop: its icon over a blurred, darkened copy of itself, a status on + * the art (Get, or how far an install is), and under it its name on up to two lines and what it + * is. [onClick] gets the art's bounds, for a page to flood out of or a dot to fly from. + */ +@Composable +private fun DesktopCard( + key: String, name: String, detail: String, art: Any?, + fallback: androidx.compose.ui.graphics.vector.ImageVector = Icons.Outlined.Apps, + first: Boolean = false, badge: String? = null, progress: Int? = null, accent: Boolean = false, tint: Color? = null, + onClick: (Rect?) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "deskCard") + val shape = RoundedCornerShape(16.dp) + var artAt by remember { mutableStateOf(null) } + val width = if (LocalNarrowPane.current) 132.dp else 156.dp + val artH = width * 0.62f + val ground = tint?.copy(alpha = 0.25f) ?: if (accent) pal.signal.copy(alpha = 0.12f) else colors.surfaceVariant + Column( + Modifier.width(width).paneItem("tile:desktop:$key").then(if (first) Modifier.firstTile() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(artAt) } + .controllerConfirm { onClick(artAt) } + .semantics { contentDescription = name }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { artAt = it.boundsInRoot() } + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(ground), + ) { + when (art) { + is Int -> { + Image( + painterResource(art), null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f } + .then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + Image(painterResource(art), null, modifier = Modifier.size(artH * 0.6f)) + } + null -> Icon(fallback, null, tint = tint ?: if (accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.42f)) + else -> { + AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f } + .then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.6f)) + } + } + Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) { + if (progress != null) Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) { + if (progress >= 0) CircularProgressIndicator(progress = { progress / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp)) + else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp)) + } + else if (badge != null) Text( + badge, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1, + modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(pal.signal).padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) { + Text(name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(detail, fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp)) + } + } +} + +/** + * The desktop sheet's Files chip: the file manager and the ROMs folder (both from Setup's old + * tools), how much the desktop takes, and Uninstall - its only home - which asks in red first. + */ +@Composable +private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current + SettingsGroup(stringResource(R.string.setup_tool_files)) { + SettingsRow(stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint)) { + SecondaryButton(stringResource(R.string.store_open), compact = true, onClick = a.onFiles) + } + SettingsRow(stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint)) { + SecondaryButton(stringResource(R.string.setup_choose), compact = true, onClick = a.onRoms) + } + } + if (s.desktopPresent) SettingsGroup(stringResource(R.string.content_linux_desktop)) { + val size by produceState(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } } + SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {} + SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) { + UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop) + } + } +} + +/** Uninstall the desktop: are you sure steps out of it in red, Keep first and focused. */ +@Composable +private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) { + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.common_uninstall) + val title = stringResource(R.string.desktop_uninstall_title) + val note = stringResource(R.string.desktop_remove_text) + val keep = stringResource(R.string.flathub_keep) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled, compact = true) { + val from = at ?: return@SecondaryButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3, + handle = { Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) }, + ) { + StepTitle(title) + Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { + StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() } + } + }) + } + } +} + +/** The add or remove under way, or why the last one failed. */ +@Composable +private fun UserAppsProgressLine() { + val colors = MaterialTheme.colorScheme + val working = UserAppsState.working + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + if (working != null) { + val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting) + val percent = UserAppsState.percent + Text( + if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage), + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + if (percent >= 0) androidx.compose.material3.LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) + else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) + } else UserAppsState.lastError?.let { Note(it) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt index 64e1bfc60..992e337a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -56,9 +56,21 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil.compose.AsyncImage import java.io.File +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned // Cover art: posters, the art grid and its tiles. +/** Where each art tile sits, root px, by its key: a page opened from one floods out of it. */ +internal object TileBounds { + val at = HashMap() + + /** The page about to open came from tile [key] (its art or icon for the flood), if it is on screen. */ + fun mark(key: String, art: Any?, corner: Float) { + at[key]?.let { PageOrigin.mark(Origin(it, corner, art = art)) } + } +} + internal class Tile( val title: String, val sub: String?, val art: File?, val key: String, val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null, @@ -102,6 +114,7 @@ internal fun ArtGrid(tiles: List, wide: Boolean = false) { val src = remember { MutableInteractionSource() } val hot = rememberHot(src) val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier) + .onGloballyPositioned { TileBounds.at[t.key] = it.boundsInRoot() } Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 3d6a4244a..0335c97cd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -76,6 +76,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -130,13 +132,21 @@ internal fun Pane( // drawing it with what is selected now (another rail item) turned it into a copy of that page. val gameShown = remember { arrayOf("games") } if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected + // What the pane showed before this change: the content a page opened over belongs to it. + val before = remember { arrayOf(target) } if (page != null && s.pageKey != null) { pages[s.pageKey] = page if (s.pageKey !in origins) PageOrigin.take()?.let { o -> // A hop's page came out of its own rail item, and is that item's content. origins[s.pageKey] = o.translate(-paneAt[0]).let { if (it.owner == null) it.ownedBy(railContent) else it } } + } else if (target != before[0] && target !in origins) { + // A section's own page opened from a control in it (an emulator's card, a ROM's) floods + // out of that control and belongs to what was showing; a hop's comes out of its rail item. + PageOrigin.take()?.let { o -> origins[target] = o.translate(-paneAt[0]).let { if (it.owner == null) it.ownedBy(before[0]) else it } } } + before[0] = target + val targetNow by rememberUpdatedState(target) // A page leaving through a hop's link is not coming back to its control. val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary LaunchedEffect(target) { Hops.linking = false } @@ -165,12 +175,19 @@ internal fun Pane( }, label = "pane", ) { key -> - val shown = if (page != null && key == s.pageKey) page else pages[key] - if (shown != null) { - DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } } + val shown = (if (page != null && key == s.pageKey) page else pages[key]) + ?: { Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) } + run { + DisposableEffect(key) { onDispose { if (key != livePage && key != targetNow) { pages.remove(key); origins.remove(key) } } } val from = origins[key] if (from == null) shown() else { + // The control's own picture (a card's art, an emulator's icon) on the tile as it grows. + val art = when (val m = from.art) { + null -> null + is Int -> androidx.compose.ui.res.painterResource(m) + else -> coil.compose.rememberAsyncImagePainter(m) + } val leaving = transition.targetState == EnterExitState.PostExit // Settled as the page starts to leave, from what is coming in, not from where it came in. val drains = remember(leaving) { !leaving || pageExit(from.owner, target, leave) == PageExit.Drain } @@ -179,10 +196,9 @@ internal fun Pane( transitionSpec = { if (targetState == EnterExitState.PostExit && drains) Motion.tw(PAGE_RETURN_MS) else snap() }, label = "pageReturn", ) { if (it == EnterExitState.PostExit) 1f else 0f } - PageFlood(from, leaving = leaving, sinks = !drains) { shown() } + PageFlood(from, leaving = leaving, art = art, sinks = !drains) { shown() } } } - else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) } } } @@ -222,6 +238,7 @@ private fun Content( val colors = MaterialTheme.colorScheme val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp val narrow = LocalNarrowPane.current + val density = androidx.compose.ui.platform.LocalDensity.current.density val padH = if (narrow) 16.dp else 22.dp val padV = if (narrow) 12.dp else 18.dp // Setup scrolls inside itself, under its tabs. @@ -255,6 +272,11 @@ private fun Content( GamesPage(s, a, selected, onSelect, modifier) return } + // Desktop is the Steam tab's shape: a hero with its lifted sheet, and shelves under it. + if (selected == "desktop") { + DesktopPage(s, a, modifier, onSelect) + return + } // Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid // instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view. Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) { @@ -275,37 +297,6 @@ private fun Content( }) } } - selected == "desktop" -> { - val installed = s.emulators.filter { it.installed } - val available = s.emulators.filter { !it.installed } - val ctx = LocalContext.current - LaunchedEffect(Unit) { UserAppsState.refresh(ctx) } - val apps = UserAppsState.items - var adding by rememberSaveable { mutableStateOf(false) } - Rise(0) { - PageHeader(stringResource(R.string.drawer_desktop)) { - if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false) - } - } - Rise(2) { DesktopCard(s, a) } - // Add closes the last grid: the installable emulators, or the installed ones once none are left. - val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) } - val addAfterReady = available.isEmpty() - if (ready.isNotEmpty() || addAfterReady) { - Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) } - Rise(4) { - LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { if (!s.busy) adding = true }) - } - } - if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() } - if (!addAfterReady) { - Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) } - Rise(6) { - LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { if (!s.busy) adding = true }) - } - } - if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) } - } selected.startsWith("user:") -> { val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected } if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect) @@ -315,21 +306,26 @@ private fun Content( if (e == null) Note(stringResource(R.string.content_not_installed)) else { val pkgId = Library.packageId(e.id) val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } } - Rise(0) { - BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") } - } + // Out of its card on Desktop, with the card's icon; B draws it back in. Rise(1) { Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(top = 4.dp, bottom = 12.dp), ) { - Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp)) + Image(painterResource(e.iconRes), null, modifier = Modifier.size(64.dp)) Column { - Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(e.name, fontSize = if (narrow) 24.sp else 30.sp, fontWeight = FontWeight.Black, color = colors.onBackground) Text( systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) }, fontSize = 14.sp, color = colors.onSurfaceVariant, ) + if (e.installed) Text( + listOfNotNull( + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size).takeIf { e.id != "retroarch" || e.games.isNotEmpty() }, + s.romsDir, + ).joinToString(" · "), + fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) } } } @@ -354,7 +350,24 @@ private fun Content( ) } else Rise(5, Modifier.fillMaxWidth()) { - ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null }) + Column { + // RetroArch's games come from many systems: chips filter them, one + // selection gliding between them. + val systems = remember(e.games) { e.games.map { romSystem(it) }.distinct() } + var system by rememberSaveable(e.id) { mutableStateOf(null) } + if (systems.size > 1) Box(Modifier.padding(top = 10.dp)) { + val labels = listOf(stringResource(R.string.desktop_all_systems)) + systems + SheetChips(labels, if (system == null) 0 else systems.indexOf(system) + 1) { i -> system = if (i == 0) null else systems[i - 1] } + } + val shown = e.games.withIndex().filter { system == null || romSystem(it.value) == system } + ArtGrid(shown.map { (index, g) -> + val key = "rom:${e.id}:$index" + Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, key, e.iconRes) { + TileBounds.mark(key, g.art ?: e.iconRes, 12f * density) + onSelect(key) + } + }, wide = e.games.none { it.art != null }) + } } } else { if (pkg != null) Rise(2) { @@ -396,12 +409,7 @@ private fun Content( val pair = romFor(s, selected) if (pair == null) Note(stringResource(R.string.content_rom_gone)) else { val (e, g) = pair - Rise(0) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - BackLink(e.name) { onSelect("emu:${e.id}") } - Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) - } - } + Rise(0) { Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) } Rise(1) { Title(g.name) } Rise(2) { Row { @@ -423,7 +431,10 @@ private fun Content( Rise(4, Modifier.fillMaxWidth()) { ArtGrid(others.map { x -> val index = e.games.indexOf(x) - Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } + Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { + TileBounds.mark("rom:${e.id}:$index", x.art ?: e.iconRes, 12f * density) + onSelect("rom:${e.id}:$index") + } }, wide = others.none { it.art != null }) } } @@ -434,122 +445,6 @@ private fun Content( } } -/** The Linux desktop itself, above the emulators that run in it. */ -@Composable -private fun DesktopCard(s: FrontEndState, a: FrontEndActions) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - var confirmRemove by rememberSaveable { mutableStateOf(false) } - if (confirmRemove) ConfirmDialog( - stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text), - stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false }, - ) - val actions: @Composable () -> Unit = { - Actions { - // Enabled without a runtime or the desktop: the session's loading screen installs them first. - PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop) - Cog(onClick = a.onDesktopSettings) - if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall), - enabled = !s.runtimeActionsBlocked) { confirmRemove = true } - BusyChip(s) - } - } - // The button goes under the title unless the card is wide enough for both side by side: in some - // languages its label alone takes half a small phone's pane. - BoxWithConstraints(Modifier.fillMaxWidth()) { - val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth - Column( - verticalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14), - ) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) } - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - if (!narrow) actions() - } - if (narrow) actions() - } - } -} - -/** How wide the desktop card must be to keep its button beside the title. */ -private val DesktopCardInlineWidth = 640.dp - -/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */ -private sealed class GridItem(val key: String) { - class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}") - class User(val app: UserApps.App) : GridItem("user:${app.key}") - data object Add : GridItem("add") -} - -/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */ -@Composable -private fun LauncherGrid(items: List, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) { - BoxWithConstraints(Modifier.fillMaxWidth()) { - // A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the - // name no room at all. - val columns = when { - LocalNarrowPane.current -> 1 - maxWidth < 720.dp -> 2 - else -> 3 - } - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) { - items.chunked(columns).forEachIndexed { r, row -> - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - row.forEachIndexed { i, item -> - key(item.key) { - val m = Modifier.weight(1f).fillMaxHeight() - val isFirst = first && r == 0 && i == 0 - when (item) { - is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) } - is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) } - GridItem.Add -> AddTile(m, isFirst, onAdd) - } - } - } - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } - } -} - -/** A grid tile's frame: the emulator tiles' look, focus glide and press. */ -@Composable -private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale") - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier.paneItem("tile:$id").then(if (isFirst) Modifier.firstTile() else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (filled) colors.surface else Color.Transparent) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) { content() } -} - -@Composable -private fun TileText(title: String, detail: String, modifier: Modifier) { - val colors = MaterialTheme.colorScheme - Column(modifier = modifier) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} - internal fun UserApps.Kind.label() = when (this) { UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage @@ -571,26 +466,6 @@ private fun UserAppIcon(app: UserApps.App, size: Int) { } } -@Composable -private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) = - TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) { - UserAppIcon(app, 44) - TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f)) - } - -/** The grid's last tile: opens the Add dialog. */ -@Composable -private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { - val pal = LocalPalette.current - TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12), - ) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) } - TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f)) - } -} - /** The add or remove under way, or why the last one failed. */ @Composable private fun UserAppsProgress() { @@ -610,20 +485,17 @@ private fun UserAppsProgress() { } } -/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */ +/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (asked first). */ @Composable private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) { val colors = MaterialTheme.colorScheme val ctx = LocalContext.current val narrow = LocalNarrowPane.current - var confirm by remember(app.key) { mutableStateOf(false) } var editing by rememberSaveable(app.key) { mutableStateOf(false) } val x86 = (app.arch == "x86_64" || app.arch == "i386") && app.fex != com.droiddeck.launcher.runtime.LinuxFex.OFF val fexReady by produceState(null, app.key, x86, s.sessionRunning) { value = if (x86) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.LinuxFex.ready(ctx) } else true } - LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } } - Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } } Rise(1) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), @@ -646,12 +518,8 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, BusyChip(s) SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true } if (app.repo != null) UpdateButton(app, s) - SecondaryButton( - stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove), - enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null, - ) { - if (!confirm) confirm = true - else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") } + RemoveAppButton(app, enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null) { + UserAppsState.remove(ctx, app); onSelect("desktop") } if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false) } @@ -672,6 +540,31 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, } } +/** Remove, which asks first: are you sure steps out of it in red, Keep first and focused. */ +@Composable +private fun RemoveAppButton(app: UserApps.App, enabled: Boolean, onRemove: () -> Unit) { + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.user_apps_remove) + val title = stringResource(R.string.flathub_remove_title, app.name) + val keep = stringResource(R.string.flathub_keep) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled) { + val from = at ?: return@SecondaryButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 2, + handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) }, + ) { + StepTitle(title) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(1)) { + StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); onRemove() } + } + }) + } + } +} + /** Looks up a newer release of an app added from GitHub, then installs it. */ @Composable private fun UpdateButton(app: UserApps.App, s: FrontEndState) { @@ -703,21 +596,5 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) { private fun systemLabel(e: Library.Emulator): String = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() } -@Composable -private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val system = systemLabel(e) - val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system - TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) { - Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (!e.installed) Text( - stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp), - ) - } -} +/** The system a ROM belongs to: the folder it sits in under the ROMs folder. */ +private fun romSystem(rom: Library.Rom): String = rom.hostPath.parentFile?.name.orEmpty() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index eb2a7acc9..05c4d5335 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -435,7 +435,12 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val pageBack = { if (!Hops.back(a.onPageBack)) a.onPageBack() } BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { pageBack() } // Left any other way, the hop's page has no way back to keep. - LaunchedEffect(s.pageKey) { Hops.forget(s.pageKey) } + LaunchedEffect(s.pageKey, selected) { Hops.forget(s.pageKey ?: selected) } + // A section reached by a hop (Desktop's Flathub card to the Store) goes back the way it came. + BackHandler(enabled = !processSettingsPageVisible && s.pageKey == null && Hops.origin?.page == selected) { + val rail = Hops.origin?.rail ?: return@BackHandler + Hops.back { selected = rail } + } val density = androidx.compose.ui.platform.LocalDensity.current.density remember(density) { Hops.density = density; true } // Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link @@ -538,11 +543,12 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val railFocus = Modifier .focusProperties { enter = { frontFocus.railFor(railSelection) } } .focusGroup() - // Back from a hop onto a surface that is not a sheet (a game's Components card): focus + // Back from a hop onto a surface that is not a sheet (a game's Components card, Desktop's + // Flathub card): focus // goes back to the control that asked, once the page has gone. LaunchedEffect(Hops.restore) { val r = Hops.restore ?: return@LaunchedEffect - if (r.surface != HOP_SURFACE_GAMES) return@LaunchedEffect + if (r.surface == HOP_SURFACE_SHEET) return@LaunchedEffect Hops.restore = null if (r.rail != selected && !(r.rail == "games" && selected.startsWith("app:"))) selected = r.rail kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) @@ -551,6 +557,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() } } var screenAt by remember { mutableStateOf(Offset.Zero) } + CompositionLocalProvider(LocalShowSection provides { key -> selected = key }) { Row(modifier = Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }) { SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight()) Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) @@ -567,6 +574,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } + } // Dots on their way somewhere: a hop to another section and back, a swap flying home. FlightsLayer(screenAt) // A confirm stepping out of the control that asked for it. @@ -634,6 +642,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } val hasBackTarget = (s.pageKey != null && page != null) || + // A section reached by a hop goes back to where it came from, not to the rail. + (s.pageKey == null && Hops.origin?.page == selected) || ((s.pageKey == null || page == null) && (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:"))) // At the top of a section, Back goes to the rail - the launcher itself is never backed out of. diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt b/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt index 3b82da881..359d2cb83 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt @@ -53,6 +53,7 @@ internal class HopOrigin( /** The surfaces a hop can start from and come back to. */ internal const val HOP_SURFACE_GAMES = "games" +internal const val HOP_SURFACE_DESKTOP = "desktop" internal const val HOP_SURFACE_SHEET = "sheet" internal object Hops { @@ -136,6 +137,9 @@ internal object Hops { var density = 1f } +/** Shows a rail section directly (no page closing, nothing forgotten): what a hop's way back calls. */ +internal val LocalShowSection = androidx.compose.runtime.staticCompositionLocalOf<(String) -> Unit> { {} } + /** Remembers where this control sits, so a hop from it can fly from it and back to it. */ internal fun Modifier.hopSource(key: String): Modifier = onGloballyPositioned { Hops.sources[key] = it.boundsInRoot() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt index 5403729dc..09c594e9a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -193,6 +193,13 @@ internal fun LiftHero( button: @Composable (onClick: () -> Unit) -> Unit, rows: @Composable (tab: String) -> Unit, modifier: Modifier = Modifier, + /** + * Down on the button lifts the sheet (Play). False: Down goes to the lip, which A lifts, and Down + * from the lip goes on past it to what is under the hero (Desktop's shelves). + */ + downOpens: Boolean = true, + /** Told when the sheet starts to lift (true) and once it has gone (false). */ + onOpenChange: (Boolean) -> Unit = {}, start: Dp = 44.dp, bottom: Dp = 44.dp, ) { @@ -313,10 +320,14 @@ internal fun LiftHero( } } BackHandler(enabled = open && !launching) { close() } + val openNow = open || shown + LaunchedEffect(openNow) { onOpenChange(openNow) } // The lip: there while Play has a pad or a pointer on it, or always with touch alone. val touchOnly = inputMode.inputMode == InputMode.Touch - val lipWanted = !open && !shown && (playFocused || playHovered || touchOnly) + val lipFocus = remember { FocusRequester() } + var lipFocused by remember { mutableStateOf(false) } + val lipWanted = !open && !shown && (playFocused || playHovered || touchOnly || lipFocused) val lipNow = rememberUpdatedState(lip) LaunchedEffect(lipWanted) { if (lipWanted) { @@ -487,7 +498,9 @@ internal fun LiftHero( if (k.keyCode != KeyEvent.KEYCODE_DPAD_DOWN) false else { if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) { - if (!open) open = true else runCatching { firstChip.requestFocus() } + if (open) runCatching { firstChip.requestFocus() } + else if (downOpens) open = true + else runCatching { lipFocus.requestFocus() } } true } @@ -502,8 +515,10 @@ internal fun LiftHero( // The lip, under Play: stretched by the pull, gone as the sheet lifts. val r0 = restRect - if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f)) { + // A lip a pad can reach stays composed (unseen at rest) so Down always has it to land on. + if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f || (!downOpens && !open && !shown))) { val lipH = 26.dp + pull.value.dp + val lift = { if (!open) open = true } Box( Modifier .offset { IntOffset((r0.left + 16.dp.toPx()).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) } @@ -517,10 +532,15 @@ internal fun LiftHero( if (dy < -24.dp.toPx() && !open) { change.consume(); open = true; dy = 0f } } } - .focusProperties { canFocus = false } - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button) { if (!open) open = true }, + .then( + if (downOpens) Modifier.focusProperties { canFocus = false } + else Modifier.focusRequester(lipFocus).onFocusChanged { lipFocused = it.isFocused }.controllerConfirm(onClick = lift), + ) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = lift), ) { - Lip(lip(), lit, lipLit.value, lipH) + Box(Modifier.glideBorder(lipFocused, RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp), LocalPalette.current.signal)) { + Lip(lip(), lit, lipLit.value, lipH) + } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt b/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt index 5e04b90a9..91a8eaa60 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt @@ -46,11 +46,12 @@ import kotlin.math.sin /** * Where a page opened from: the control's bounds and corner radius, in px, and the rail item that - * was showing it ([owner]), so the page only draws back into it when that content returns. + * was showing it ([owner]), so the page only draws back into it when that content returns. [art] + * is the control's picture for the tile (a drawable id, or an image Coil loads), if it has one. */ -internal class Origin(val bounds: Rect, val corner: Float, val owner: String? = null) { - fun translate(by: Offset) = Origin(bounds.translate(by), corner, owner) - fun ownedBy(key: String) = Origin(bounds, corner, key) +internal class Origin(val bounds: Rect, val corner: Float, val owner: String? = null, val art: Any? = null) { + fun translate(by: Offset) = Origin(bounds.translate(by), corner, owner, art) + fun ownedBy(key: String) = Origin(bounds, corner, key, art) } /** How a page that opened from a control leaves. */ diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index 48fadb62b..0c64b9043 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -113,7 +113,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) /** The wall fades out behind the words, and toward the bottom where the buttons sit. */ @Composable -private fun WallFade() { +internal fun WallFade() { val colors = MaterialTheme.colorScheme Box( Modifier.fillMaxSize().background( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index 6a2161f95..c64a3d87c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -331,7 +331,14 @@ private fun Storefront(s: FrontEndState, a: FrontEndActions) { onNext = { if (openApp == null) mode = modes[(modes.indexOf(mode) + 1) % modes.size] }, ), ) { - Rise(0) { FlathubChips(mode, modes, StoreState.installed.size, updates.size) { mode = it } } + Rise(0) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.weight(1f)) { FlathubChips(mode, modes, StoreState.installed.size, updates.size) { mode = it } } + // Reached by a hop from Desktop: the way back. + val show = LocalShowSection.current + HopBackChip { Hops.origin?.let { o -> Hops.back { show(o.rail) } } } + } + } Spacer(Modifier.height(8.dp)) Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().onGloballyPositioned { viewportAt = it.positionInRoot() }) { Column( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 762744bb3..6841d8730 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2217,4 +2217,11 @@ Remove %1$s? Its settings and files are kept. Keep + + KDE Plasma + Nothing yet. Get an emulator or add an app below. + Get more + Size on disk + Uninstall the desktop? + All