diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 8f611bfb2..f24467449 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -13,15 +13,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -41,8 +46,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily 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 com.droiddeck.launcher.session.ComponentsManager @@ -53,7 +61,7 @@ import java.util.Date /** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */ private class InstalledItem( - val key: String, val name: String, val detail: String, val tag: String, + val name: String, val detail: String, val tag: String, val selected: Boolean, val removable: Boolean, /** Stored package file, or null for an original. */ val file: String?, @@ -61,12 +69,16 @@ private class InstalledItem( val protonVersion: String?, ) +private val ROW_HEIGHT: Dp = 40.dp +private val GOLD = Color(0xFFF2C66D) + /** * FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry. - * Built like the driver pages: pick a Proton (drop-down) and a component (tabs); the installed - * rows are the Proton's original bundles and the stored packages, the ● is what that Proton uses, - * tapping another row swaps it in; downloads come from the Nightlies "-Linux" releases, and the - * refresh button beside the title is the only thing that goes online. + * Compact on purpose (layout A of the mock): title, Proton drop-down, component tabs, what is in + * use and refresh share one toolbar line, the explanation sits behind the (i), and installed rows + * and the Nightlies' downloads run as one list of short rows - a landscape screen shows a dozen or + * more instead of two. Tapping an installed row swaps it in; the refresh button is the only thing + * that goes online. */ @Composable fun ComponentsPage( @@ -90,170 +102,158 @@ fun ComponentsPage( onImport: () -> Unit, onBack: () -> Unit, ) { - val host = rememberMenuHost() val colors = MaterialTheme.colorScheme val pal = LocalPalette.current BackHandler(onBack = onBack) var confirm by remember { mutableStateOf Unit>?>(null) } var confirmTitle by remember { mutableStateOf("") } + var about by remember { mutableStateOf(false) } + var protonMenu by remember { mutableStateOf(false) } fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action } val views = snapshot?.protons ?: emptyList() val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull() val label = ComponentsManager.LABEL[comp] ?: comp - val checked = if (catalogAt > 0) DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never" - val status = buildString { - append("Nightlies last checked $checked") - if (busy != null) append(" · $busy…") - } + val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never" - val refreshSrc = remember { MutableInteractionSource() } - val refreshHot = refreshSrc.collectIsFocusedAsState().value || refreshSrc.collectIsHoveredAsState().value - SettingsPage( - host, title = "Components", onBack = onBack, - lede = "FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with. Tap a row to swap it in; it applies the next time a game starts.\n$status", - action = { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) - .background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp)) - .hoverable(refreshSrc) - .clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh), - ) { - if (checking) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) - else Icon(Icons.Outlined.Refresh, contentDescription = "Check the Nightlies for new packages", tint = colors.onBackground, modifier = Modifier.size(20.dp)) - } - }, - ) { - if (snapshot == null) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 20.dp)) { + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp)) { + // ---- the toolbar: one line ------------------------------------------------------------ + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp)) { + FocusText("‹ Back", colors.onSurfaceVariant, onClick = onBack) + Text("Components", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + if (view != null) { + Box { + ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 260.dp)) { protonMenu = !protonMenu } + AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton", + note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first -> + views.forEachIndexed { i, v -> + val swaps = v.components.values.count { it.activeFile != null } + MenuItem( + v.proton.name, checked = v.proton.id == view.proton.id, + detail = v.proton.version + (if (swaps > 0) " · $swaps swapped" else " · all original") + (if (v.inUseByGame) " · game running" else ""), + focusRequester = if (i == 0) first else null, + ) { onProton(v.proton.id); protonMenu = false } + } + } + } + Segmented(ComponentsManager.COMPONENTS.map { it to ComponentsManager.LABEL.getValue(it) }, comp, onComp) + val st = view.components.getValue(comp) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape)) + Spacer(Modifier.width(6.dp)) + Text( + if (st.queued != null) "Next: ${st.queued} · after the game" else "In use: ${st.inUse}", + fontSize = 12.5.sp, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), + ) + if (st.queued != null) { Spacer(Modifier.width(8.dp)); FocusText("Cancel", pal.signal, onClick = onCancelQueued) } + } + } else Spacer(Modifier.weight(1f)) + Text(if (busy != null) "$busy…" else "Nightlies · $checked", fontSize = 11.5.sp, color = colors.onSurfaceVariant, maxLines = 1) + ToolIcon(Icons.Outlined.Info, "About Components") { about = true } + ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh) + } + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + + // ---- the list --------------------------------------------------------------------------- + when { + snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(12.dp)) Text("Reading the Protons…", fontSize = 13.sp, color = colors.onSurfaceVariant) } - return@SettingsPage - } - if (view == null) { - SettingsGroup("Proton") { - Text( - "No Proton is installed in the Linux runtime yet. Start the Steam client once so it downloads its ARM64 Proton, or add GE-Proton / CachyOS from Setup.", - fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp), - ) - } - return@SettingsPage - } - val p = view.proton - SettingsGroup("Proton") { - ChoiceRow( - host, "components-proton", p.name, - p.version + (if (view.inUseByGame) " · a game is running on it" else "") + (if (p.valve) " · updated by Steam" else ""), - views.map { v -> - val swaps = v.components.values.count { it.activeFile != null } - v.proton.id to (v.proton.name + if (swaps > 0) " · $swaps swapped" else "") - }, - p.id, - note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.", - onPick = onProton, + view == null -> Text( + "No Proton is installed in the Linux runtime yet. Start the Steam client once so it downloads its ARM64 Proton, or add GE-Proton / CachyOS from Setup.", + fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(16.dp), ) - } + else -> { + val p = view.proton + val st = view.components.getValue(comp) + val build = ComponentsManager.safeName(p.version) + val originals = view.originals.filter { it.comp == comp } + val stored = snapshot.packages.filter { it.comp == comp } + val activeIsOriginal = st.activeFile == null + val rows = originals.map { o -> + val isCurrent = o.protonVersion == build + InstalledItem( + "Original · ${o.protonVersion}", + o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · earlier build", + "ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion, + ) + } + stored.map { s -> + InstalledItem(s.version, String.format("%.1f MB", s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null) + } + val storedNames = snapshot.packages.map { it.file }.toSet() + val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames } + val first = remember { FocusRequester() } + LaunchedEffect(p.id, comp) { runCatching { first.requestFocus() } } - // Component tabs. - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) { - for (c in ComponentsManager.COMPONENTS) CompTab(ComponentsManager.LABEL.getValue(c), c == comp) { onComp(c) } - } - Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) - - val state = view.components.getValue(comp) - SettingsGroup("In use") { - SettingsRow( - state.inUse, - "Detected: ${state.detected}" + (if (state.detail.isNotEmpty()) " · ${state.detail}" else "") + - (state.queued?.let { "\nNext: $it, when the game running on this Proton closes" } ?: ""), - ) { - if (state.queued != null) FocusText("Cancel", pal.signal, onClick = onCancelQueued) - else Box(Modifier.size(10.dp).background(pal.good, CircleShape)) - } - } - - val originals = view.originals.filter { it.comp == comp } - val currentBuild = ComponentsManager.safeName(p.version) - val stored = snapshot.packages.filter { it.comp == comp } - val activeIsOriginal = state.activeFile == null - val items = originals.map { o -> - val isCurrent = o.protonVersion == currentBuild - InstalledItem( - "orig:${o.protonVersion}", "Original · ${o.protonVersion}", - o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · an earlier build", - "ORIGINAL", selected = activeIsOriginal && isCurrent, removable = !isCurrent, file = null, protonVersion = o.protonVersion, - ) - } + stored.map { s -> - InstalledItem( - "pkg:${s.file}", s.version, s.file + " · " + String.format("%.1f MB", s.size / 1048576.0), - "STORED", selected = s.file == state.activeFile, removable = true, file = s.file, protonVersion = null, - ) - } - SettingsGroup("Installed") { - if (items.isEmpty()) Text( - "Nothing stored for $label yet. The Proton's own files are saved as its original the first time this page opens.", - fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), - ) - val first = remember { FocusRequester() } - LaunchedEffect(p.id, comp) { runCatching { first.requestFocus() } } - items.forEachIndexed { i, item -> - ComponentRow( - item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier, - onSelect = { - if (item.selected) return@ComponentRow - if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle." + - if (view.inUseByGame) " A game is running on this Proton, so the swap waits until it closes." else " It applies the next time a game starts.") { onSwap(item.file) } - else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?" + - if (view.inUseByGame) " A game is running on this Proton, so this waits until it closes." else "") { onRestore(item.protonVersion!!) } - }, - onDelete = { - if (item.file != null) ask("Delete ${item.name}?", "Its package file is removed from the app. You can download it again any time.") { onDeletePackage(item.file) } - else ask("Delete this original?", "The ${item.protonVersion} original of $label belongs to an earlier build of ${p.name}. The installed build's original is always kept.") { onDeleteOriginal(item.protonVersion!!) } - }, - ) - } - } - - val storedNames = snapshot.packages.map { it.file }.toSet() - val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames } - SettingsGroup("Available to download") { - if (available.isEmpty()) Text( - if (catalogAt == 0L) "Tap the refresh button to list the packages on the Nightlies." else "Nothing new from the last check. Tap the refresh button to look for newer releases.", - fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), - ) else for (d in available) { - SettingsRow(d.file.removeSuffix(".wcp"), "${d.release} · " + String.format("%.1f MB", d.size / 1048576.0)) { - val pr = downloads[d.file] - if (pr == null) SecondaryButton("Download", enabled = busy == null) { onDownload(d) } - else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(120.dp)) { - Text(if (pr < 0) "…" else "$pr%", fontSize = 12.sp, color = colors.onSurfaceVariant) - Spacer(Modifier.height(4.dp)) - if (pr < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - else LinearProgressIndicator(progress = { pr / 100f }, modifier = Modifier.fillMaxWidth()) + LazyColumn(Modifier.fillMaxWidth().weight(1f)) { + item { SectionLabel("Installed · ${rows.size}") } + if (rows.isEmpty()) item { + Text("Nothing stored for $label yet. This Proton's own files are saved as its original when the page opens.", + fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) + } + items(rows.size) { i -> + val item = rows[i] + InstalledLine( + item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier, + onSelect = { + if (item.selected) return@InstalledLine + val waits = if (view.inUseByGame) " A game is running on this Proton, so it waits until the game closes." else " It applies the next time a game starts." + if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle.$waits") { onSwap(item.file) } + else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?$waits") { onRestore(item.protonVersion!!) } + }, + onDelete = { + if (item.file != null) ask("Delete ${item.name}?", "Its package file is removed from the app. You can download it again any time.") { onDeletePackage(item.file) } + else ask("Delete this original?", "The ${item.protonVersion} original of $label belongs to an earlier build of ${p.name}. The installed build's original is always kept.") { onDeleteOriginal(item.protonVersion!!) } + }, + ) + } + item { SectionLabel("Available on the Nightlies · ${available.size}") } + if (available.isEmpty()) item { + Text(if (catalogAt == 0L) "Tap refresh to list the packages on the Nightlies." else "Nothing new from the last check.", + fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) + } + items(available.size) { i -> + val d = available[i] + AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) } + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 8.dp, vertical = 10.dp)) { + SmallButton("Import .wcp", onClick = onImport) + if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText("Restore this Proton's original", pal.signal) { + ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(build) } + } + } } } } } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) { - SecondaryButton("Import .wcp", onClick = onImport) - val currentOriginal = originals.any { it.protonVersion == currentBuild } - if (!activeIsOriginal && currentOriginal) FocusText("Restore this Proton's original", pal.signal) { - ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(currentBuild) } - } - } } + if (about) AlertDialog( + onDismissRequest = { about = false }, + title = { Text("Components") }, + text = { + Text( + "FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with.\n\n" + + "Tap an installed row to swap it in; it applies the next time a game starts. If a game is running on that Proton, the swap waits until it closes.\n\n" + + "Each Proton build's own files are kept as its ORIGINAL, so a Steam update never loses them. Packages come from the Nightlies \"-Linux\" releases (refresh) or Import .wcp.", + fontSize = 13.sp, + ) + }, + confirmButton = { FocusText("OK", pal.signal) { about = false } }, + ) confirm?.let { (body, action) -> AlertDialog( onDismissRequest = { confirm = null }, title = { Text(confirmTitle) }, text = { Text(body, fontSize = 13.sp) }, // Opens on Cancel, so a stray A press on a controller never swaps or deletes anything. - confirmButton = { FocusText(if (confirmTitle.startsWith("Delete")) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap", - if (confirmTitle.startsWith("Delete")) colors.error else pal.signal) { confirm = null; action() } }, + confirmButton = { + val del = confirmTitle.startsWith("Delete") + FocusText(if (del) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap", if (del) colors.error else pal.signal) { confirm = null; action() } + }, dismissButton = { val cancelFocus = remember { FocusRequester() } LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } } @@ -264,79 +264,157 @@ fun ComponentsPage( } @Composable -private fun CompTab(text: String, selected: Boolean, onClick: () -> Unit) { +private fun SectionLabel(text: String) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) - .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) - .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) - .controllerConfirm(onClick = onClick), - ) { - Text( - text, fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = if (selected) colors.onBackground else colors.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - ) - Box(Modifier.width(48.dp).height(3.dp).clip(RoundedCornerShape(2.dp)).background(if (selected) pal.signal else Color.Transparent)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(start = 8.dp, top = 10.dp, bottom = 4.dp)) { + Text(text.uppercase(), fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp, color = colors.onSurfaceVariant) + Box(Modifier.weight(1f).height(1.dp).background(pal.line)) } } @Composable -private fun ComponentRow(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) { +private fun Segmented(options: List>, selected: String, onPick: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Row(Modifier.clip(RoundedCornerShape(9.dp)).background(colors.surfaceVariant).border(1.dp, pal.line2, RoundedCornerShape(9.dp)).padding(2.dp)) { + for ((key, text) in options) { + val on = key == selected + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val pick = { onPick(key) } + Text( + text, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, maxLines = 1, + color = if (on) Color.White else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.clip(RoundedCornerShape(7.dp)) + .background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.18f) else Color.Transparent) + .border(if (hot && !on) 1.dp else 0.dp, if (hot && !on) pal.signal else Color.Transparent, RoundedCornerShape(7.dp)) + .hoverable(src).clickable(interactionSource = src, indication = null, onClick = pick) + .controllerConfirm(onClick = pick) + .padding(horizontal = 12.dp, vertical = 5.dp), + ) + } + } +} + +@Composable +private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value - val shape = RoundedCornerShape(10.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)) + .background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(9.dp)) + .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick), + ) { + if (busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + else Icon(icon, contentDescription = description, tint = colors.onBackground, modifier = Modifier.size(18.dp)) + } +} + +@Composable +private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled + Text( + text, fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, + color = if (!enabled) colors.onSurfaceVariant else if (accent) Color(0xFF5E9BFF) else colors.onBackground, + modifier = Modifier.clip(RoundedCornerShape(8.dp)) + .background(if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) + .border(if (hot) 2.dp else 1.dp, if (hot || accent) pal.signal else pal.line2, RoundedCornerShape(8.dp)) + .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) +} + +@Composable +private fun Tag(text: String) { + val colors = MaterialTheme.colorScheme + val (fg, bg) = when (text) { + "ORIGINAL" -> GOLD to Color(0x22F2C66D) + "IN USE" -> Color.White to LocalPalette.current.signal + "NEW" -> LocalPalette.current.good to Color(0x224CD37F) + else -> colors.onSurfaceVariant to Color.White.copy(alpha = 0.07f) + } + Text( + text, fontSize = 9.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp, color = fg, maxLines = 1, + modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(bg).padding(horizontal = 5.dp, vertical = 1.dp), + ) +} + +@Composable +private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val shape = RoundedCornerShape(8.dp) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape) + modifier = Modifier.fillMaxWidth().heightIn(min = ROW_HEIGHT).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) .border(2.dp, if (hot) pal.signal else Color.Transparent, shape), ) { Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f).then(modifier) + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.weight(1f).heightIn(min = ROW_HEIGHT).then(modifier) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) .controllerConfirm(onClick = onSelect) - .padding(horizontal = 11.dp, vertical = 8.dp), + .padding(horizontal = 10.dp), ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(20.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape), - ) { if (item.selected) Box(Modifier.size(10.dp).background(pal.signal, CircleShape)) } - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false), - ) - Text( - item.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, - color = if (item.tag == "ORIGINAL") Color(0xFFF2C66D) else colors.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)) - .background(if (item.tag == "ORIGINAL") Color(0x22F2C66D) else Color.White.copy(alpha = 0.07f)) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - Text(item.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(16.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape)) { + if (item.selected) Box(Modifier.size(7.dp).background(pal.signal, CircleShape)) } + Text( + item.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (item.selected) Color(0xFF5E9BFF) else colors.onBackground, modifier = Modifier.weight(1f, fill = false), + ) + Tag(item.tag) + Text(item.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (item.selected) Tag("IN USE") } if (item.removable) { val delSrc = remember { MutableInteractionSource() } val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value Box( contentAlignment = Alignment.Center, - modifier = Modifier.padding(end = 6.dp).size(38.dp).clip(RoundedCornerShape(9.dp)) - .border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(9.dp)) + modifier = Modifier.padding(end = 4.dp).size(32.dp).clip(RoundedCornerShape(8.dp)) + .border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(8.dp)) .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) .controllerConfirm(onClick = onDelete), - ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) } + ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(17.dp)) } } } + Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).height(1.dp).background(pal.line.copy(alpha = 0.5f))) +} + +@Composable +private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDownload: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = ROW_HEIGHT).padding(horizontal = 10.dp), + ) { + Tag("NEW") + Text(d.file.removeSuffix(".wcp"), fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + Text("${d.release} · " + String.format("%.1f MB", d.size / 1048576.0), fontSize = 11.5.sp, color = colors.onSurfaceVariant, + fontFamily = FontFamily.Default, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (progress == null) SmallButton("Download", enabled = enabled, accent = true, onClick = onDownload) + else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(110.dp)) { + Text(if (progress < 0) "…" else "$progress%", fontSize = 11.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(3.dp)) + if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth()) + } + } + Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).height(1.dp).background(pal.line.copy(alpha = 0.5f))) }