Components: compact layout - one toolbar line (title, Proton drop-down, component tabs, in use, (i), refresh) and one list of short rows for installed and available packages

This commit is contained in:
The412Banner committed 2026-09-25 22:27:25 -04:00
1 parent cbb9a168e6
commit fa12478fdd
1 file changed
+263 -185
@@ -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<Pair<String, () -> 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<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) 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)))
}