From f59ced4e985832b0fa2dbf086db3e932e1716167 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sat, 26 Sep 2026 16:38:53 -0400 Subject: [PATCH] feat(ui): rework the components page layout --- .../droiddeck/launcher/ui/ComponentsPage.kt | 339 +++++++++--------- 1 file changed, 160 insertions(+), 179 deletions(-) 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 a89c2a24e..2dcf848cd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -21,7 +21,8 @@ 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -69,19 +70,16 @@ private class InstalledItem( val protonVersion: String?, ) -private val ROW_HEIGHT: Dp = 40.dp -private val DETAIL_WIDTH: Dp = 260.dp -private val ACTION_WIDTH: Dp = 112.dp private val GOLD = Color(0xFFF2C66D) +private val ROW_SHAPE = RoundedCornerShape(10.dp) /** * FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry. - * 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. + * A title row with the Nightlies refresh, then the Proton and the component (LB / RB turn it), what + * is in use, and the installed packages beside the ones on the Nightlies - stacked when the page is + * narrow. Tapping an installed row swaps it in; the refresh button is the only thing that goes online. */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable fun ComponentsPage( snapshot: Snapshot?, @@ -106,6 +104,7 @@ fun ComponentsPage( ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current + val narrow = LocalNarrowPane.current BackHandler(onBack = onBack) var confirm by remember { mutableStateOf Unit>?>(null) } var confirmTitle by remember { mutableStateOf("") } @@ -117,60 +116,76 @@ fun ComponentsPage( val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull() val label = ComponentsManager.LABEL[comp] ?: comp val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never" + val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked" - 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 } - } - } - } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - BumperHint("LB") - Segmented(ComponentsManager.COMPONENTS.map { it to ComponentsManager.LABEL.getValue(it) }, comp, onComp) - BumperHint("RB") - } - 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)) - val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else "" - Text( - (if (st.queued != null) "Next: ${st.queued} · after the game" else "In use: ${st.inUse}") + fixedAt, - 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) + Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { + // ---- title, and the one thing that goes online ----------------------------------------- + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Text( + "Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + maxLines = 1, modifier = Modifier.weight(1f), + ) + if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) 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)) + if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - // ---- the list --------------------------------------------------------------------------- + // ---- which Proton, which component ------------------------------------------------------ + val comps = ComponentsManager.COMPONENTS + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) { + if (view != null) Box { + ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.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 } + } + } + } + TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) }) + } + + // ---- what is in use ----------------------------------------------------------------------- + if (view != null) { + val st = view.components.getValue(comp) + val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else "" + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) + .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp), + ) { + Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape)) + Text( + (if (st.queued != null) "Next: ${st.queued} · after the game closes" else "$label in use: ${st.inUse}") + fixedAt, + fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), + ) + if (st.queued != null) FocusText("Cancel", pal.signal, onClick = onCancelQueued) + else if (!narrow) Text( + if (view.inUseByGame) "Waits until the game closes" else "Changes apply the next time a game starts", + fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, + ) + } + } + + // ---- the lists ------------------------------------------------------------------------------ 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) + Text("Reading the Protons…", fontSize = 14.sp, color = colors.onSurfaceVariant) } 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), + fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp), ) else -> { val p = view.proton @@ -194,43 +209,48 @@ fun ComponentsPage( val first = remember { FocusRequester() } LaunchedEffect(p.id, comp) { runCatching { first.requestFocus() } } - 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!!) } - }, + val installed: @Composable () -> Unit = { + SettingsGroup("Installed · ${rows.size}") { + if (rows.isEmpty()) Text( + "Nothing stored for $label yet. This Proton's own files are saved as its original when the page opens.", + fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), ) + rows.forEachIndexed { i, item -> + 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)) + } + val nightly: @Composable () -> Unit = { + SettingsGroup("On the Nightlies · ${available.size}") { + if (available.isEmpty()) Text( + if (catalogAt == 0L) "Refresh to list the packages on the Nightlies." else "Nothing new since the last check.", + fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + ) + available.forEach { d -> AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) } } } - items(available.size) { i -> - val d = available[i] - AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) } + } + Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { + if (narrow) { installed(); nightly() } + else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { installed() } + Column(Modifier.weight(1f)) { nightly() } } - 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(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.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) } } } } @@ -246,8 +266,8 @@ fun ComponentsPage( "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" + "Your choices are also checked right before every game launch: if anything changed a Proton's files (Steam's start-up tests, for one), they are put back first, and the page says \"re-applied at launch\".\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, + "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 = 14.sp, ) }, confirmButton = { FocusText("OK", pal.signal) { about = false } }, @@ -256,7 +276,7 @@ fun ComponentsPage( AlertDialog( onDismissRequest = { confirm = null }, title = { Text(confirmTitle) }, - text = { Text(body, fontSize = 13.sp) }, + text = { Text(body, fontSize = 14.sp) }, // Opens on Cancel, so a stray A press on a controller never swaps or deletes anything. confirmButton = { val del = confirmTitle.startsWith("Delete") @@ -271,51 +291,6 @@ fun ComponentsPage( } } -/** The pad's bumper that turns the component tabs, as a small badge beside them. */ -@Composable -private fun BumperHint(text: String) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - Text( - text, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, color = colors.onSurfaceVariant, maxLines = 1, - modifier = Modifier.clip(RoundedCornerShape(5.dp)).border(1.dp, pal.line2, RoundedCornerShape(5.dp)).padding(horizontal = 5.dp, vertical = 2.dp), - ) -} - -@Composable -private fun SectionLabel(text: String) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - 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 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) pal.onSignal 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 @@ -324,20 +299,20 @@ private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fal val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value Box( contentAlignment = Alignment.Center, - modifier = Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)) + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.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)) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.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)) + if (busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + else Icon(icon, contentDescription = description, tint = colors.onBackground, modifier = Modifier.size(20.dp)) } } /** * A small button. Resting: grey outline (blue text when [accent]). Focused or hovered: a solid blue - * fill, white text and a white outline - unmistakable in a column of identical buttons. + * fill with an outline in the text colour - unmistakable in a column of identical buttons. */ @Composable private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { @@ -347,15 +322,15 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled Box( contentAlignment = Alignment.Center, - modifier = modifier.clip(RoundedCornerShape(8.dp)) + modifier = modifier.heightIn(min = 40.dp).clip(RoundedCornerShape(10.dp)) .background(if (hot) pal.signal else colors.surfaceVariant) - .border(2.dp, if (hot) Color.White else pal.line2, RoundedCornerShape(8.dp)) + .border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp)) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) - .padding(horizontal = 10.dp, vertical = 5.dp), + .padding(horizontal = 14.dp, vertical = 8.dp), ) { Text( - text, fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, + text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, color = if (!enabled) colors.onSurfaceVariant else if (hot) pal.onSignal else if (accent) pal.signal else colors.onBackground, ) } @@ -364,15 +339,16 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = @Composable private fun Tag(text: String) { val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current val (fg, bg) = when (text) { "ORIGINAL" -> GOLD to Color(0x22F2C66D) - "IN USE" -> LocalPalette.current.onSignal to LocalPalette.current.signal - "NEW" -> LocalPalette.current.good to Color(0x224CD37F) + "IN USE" -> pal.onSignal to pal.signal + "NEW" -> pal.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), + text.lowercase().replaceFirstChar { it.uppercase() }, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, + modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(bg).padding(horizontal = 7.dp, vertical = 1.dp), ) } @@ -382,46 +358,50 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on 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().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), + modifier = Modifier.fillMaxWidth() + .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.07f) else Color.Transparent), ) { Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.weight(1f).heightIn(min = ROW_HEIGHT).then(modifier) + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier) + .border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) .controllerConfirm(onClick = onSelect) - .padding(horizontal = 10.dp), + .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), ) { - 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)) + 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(9.dp).background(pal.signal, CircleShape)) } - Text( - item.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f), - ) - Box(Modifier.width(72.dp)) { Tag(item.tag) } - Text(item.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(DETAIL_WIDTH)) - Box(Modifier.width(56.dp), contentAlignment = Alignment.CenterEnd) { if (item.selected) Tag("IN USE") } + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + 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), + ) + Tag(item.tag) + } + Text(item.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp)) + } + if (item.selected) Tag("IN USE") } // The trash column is always there (empty when the row can't be deleted) so rows line up. - if (!item.removable) Spacer(Modifier.padding(end = 4.dp).size(32.dp)) + if (!item.removable) Spacer(Modifier.padding(end = 6.dp).size(40.dp)) else { val delSrc = remember { MutableInteractionSource() } val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value Box( contentAlignment = Alignment.Center, - 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)) + modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE) + .background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent) + .border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE) .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(17.dp)) } + ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } } } - Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).height(1.dp).background(pal.line.copy(alpha = 0.5f))) + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } @Composable @@ -429,22 +409,23 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo 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), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 14.dp, end = 10.dp, top = 8.dp, bottom = 8.dp), ) { - // Fixed columns so every row's details and button line up: name | details | button. - 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)) - 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.width(DETAIL_WIDTH)) - if (progress == null) SmallButton("Download", enabled = enabled, accent = true, modifier = Modifier.width(ACTION_WIDTH), onClick = onDownload) - else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(ACTION_WIDTH)) { - Text(if (progress < 0) "…" else "$progress%", fontSize = 11.sp, color = colors.onSurfaceVariant) - Spacer(Modifier.height(3.dp)) + Column(Modifier.weight(1f)) { + Text(d.file.removeSuffix(".wcp"), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + "${d.release} · " + String.format("%.1f MB", d.size / 1048576.0), fontSize = 13.sp, color = colors.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp), + ) + } + if (progress == null) SmallButton("Download", enabled = enabled, accent = true, onClick = onDownload) + else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { + Text(if (progress < 0) "…" else "$progress%", fontSize = 13.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(4.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))) + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) }