feat(ui): rework the components page layout

This commit is contained in:
xXJsonDeruloXx committed 2026-09-26 16:38:53 -04:00
1 parent 296e848718
commit f59ced4e98
1 file changed
+160 -179
@@ -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<Pair<String, () -> 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<Pair<String, String>>, 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))
}