feat(ui): Desktop in the Steam tab's shape, with its own lifted sheet

This commit is contained in:
xXJsonDeruloXx committed 2026-10-11 11:38:42 -04:00
1 parent 308f53432c
commit bb4aee7027
12 files changed
+625 -230

No files matched your search

@@ -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)
@@ -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)
@@ -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<Rect?>(null) }
var lastCardAt by remember { mutableStateOf<Rect?>(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<String>() }
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<Any>) {
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<Rect?>(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<Long?>(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<Rect?>(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) }
}
}
@@ -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<String, androidx.compose.ui.geometry.Rect>()
/** 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<Tile>, 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) }
}
}
@@ -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<String?>(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<GridItem>, 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<Boolean?>(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<androidx.compose.ui.geometry.Rect?>(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()
@@ -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.
@@ -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() }
@@ -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)
}
}
}
}
@@ -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. */
@@ -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(
@@ -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(
+7
View File
@@ -2217,4 +2217,11 @@
<string name="flathub_remove_title">Remove %1$s?</string>
<string name="flathub_remove_note">Its settings and files are kept.</string>
<string name="flathub_keep">Keep</string>
<!-- Desktop in the Steam tab's shape. -->
<string name="desktop_shell_kde">KDE Plasma</string>
<string name="desktop_no_apps">Nothing yet. Get an emulator or add an app below.</string>
<string name="desktop_get_more">Get more</string>
<string name="desktop_size">Size on disk</string>
<string name="desktop_uninstall_title">Uninstall the desktop?</string>
<string name="desktop_all_systems">All</string>
</resources>