diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 6b716a17b..1fc000a14 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -109,8 +109,9 @@ class MainActivity : ComponentActivity() { /** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */ private var linuxDownloads by mutableStateOf>(emptyList()) private var androidDownloads by mutableStateOf>(emptyList()) - private var releaseStatus by mutableStateOf("Check for new drivers (Banners-Turnip, WinNative)") - private var releaseChecking = false + private var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers") + private var releaseChecking by mutableStateOf(false) + private var canRestoreBundled by mutableStateOf(false) /** Asset name -> download percent, while it downloads. */ private val releaseProgress = HashMap() private var linuxSteam by mutableStateOf("") @@ -526,6 +527,7 @@ class MainActivity : ComponentActivity() { addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), addedGamesArt = addedGamesArt, linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, releaseStatus = releaseStatus, + releaseChecking = releaseChecking, canRestoreBundled = canRestoreBundled, ), ModeSettingsActions( onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap }, @@ -537,6 +539,7 @@ class MainActivity : ComponentActivity() { onRemoveLinux = { id -> deleteDriver(id, linux = true) }, onRefreshReleases = { checkLatestTurnip() }, onDownloadDriver = { name -> downloadReleaseDriver(name) }, + onRestoreBundled = { TurnipDriver(this).restoreBundled(); refreshDrivers() }, onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); refreshDrivers() }, onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) }, onRemoveAndroid = { id -> deleteDriver(id, linux = false) }, @@ -662,6 +665,7 @@ class MainActivity : ComponentActivity() { private fun refreshDrivers() { val lm = LinuxVulkanDriverManager(this) + fun origin(id: String) = if (TurnipReleases.isDownloaded(this, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED linuxRows = LinuxVulkanDriver.optionValues(this).map { id -> if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false) else DriverRow( @@ -669,8 +673,8 @@ class MainActivity : ComponentActivity() { listOfNotNull( lm.getDriverVersion(id).takeIf { it.isNotEmpty() }, lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" }, - ).joinToString(" · ").ifEmpty { "imported" }, - true, + ).joinToString(" · "), + true, origin(id), ) } linuxSteam = SessionPrefs.linuxDriver(this, SessionService.MODE_STEAM) @@ -683,9 +687,10 @@ class MainActivity : ComponentActivity() { if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)", false, )) - for (id in TurnipDriver.BUNDLED) add(DriverRow(id, td.displayName(id), "bundled" + td.driverVersion(id).let { if (it.isEmpty()) "" else " · $it" }, false)) - for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), "imported" + td.driverVersion(id).let { if (it.isEmpty()) "" else " · $it" }, true)) + for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED)) + for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id))) } + canRestoreBundled = td.hiddenBundled().isNotEmpty() androidSelected = SessionPrefs.androidDriver(this) refreshReleaseRows() } @@ -729,7 +734,8 @@ class MainActivity : ComponentActivity() { if (SessionPrefs.linuxDriver(this, mode) == id) SessionPrefs.setLinuxDriver(this, mode, "") } } else { - TurnipDriver(this).remove(id) + val td = TurnipDriver(this) + if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id) if (SessionPrefs.androidDriver(this) == id) SessionPrefs.setAndroidDriver(this, TurnipDriver.AUTO) } TurnipReleases.forget(this, id) @@ -747,18 +753,15 @@ class MainActivity : ComponentActivity() { .filter { a -> TurnipReleases.installedId(this, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null } .map { a -> val mb = "%.1f MB".format(a.size / 1_048_576.0) - com.steamdeck.launcher.ui.DownloadRow( - a.name, "Download ${a.source} ${a.tag}", - releaseProgress[a.name]?.let { "${a.label} · downloading $it%" } ?: "${a.label} · $mb", - ) + com.steamdeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name]) } linuxDownloads = rows(linux = true) androidDownloads = rows(linux = false) if (!releaseChecking) releaseStatus = when (check) { - null -> "Check for new drivers (Banners-Turnip, WinNative)" + null -> "Not checked yet - tap refresh to look for new drivers" else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } + (if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") + - " · checked ${ago(check.checkedAt)} · tap to refresh" + " · checked ${ago(check.checkedAt)}" } } @@ -784,7 +787,7 @@ class MainActivity : ComponentActivity() { ui.post { releaseChecking = false refreshReleaseRows() - if (problem != null) releaseStatus = "Couldn't check: $problem · tap to retry" + if (problem != null) releaseStatus = "Couldn't check: $problem" } }, "turnip-release-check").start() } diff --git a/app/src/main/java/com/steamdeck/launcher/gpu/TurnipDriver.java b/app/src/main/java/com/steamdeck/launcher/gpu/TurnipDriver.java index 65d1ade06..6ac4c3858 100644 --- a/app/src/main/java/com/steamdeck/launcher/gpu/TurnipDriver.java +++ b/app/src/main/java/com/steamdeck/launcher/gpu/TurnipDriver.java @@ -52,6 +52,9 @@ public final class TurnipDriver { public static final List BUNDLED = Collections.unmodifiableList(Arrays.asList(DRIVER_A7XX, DRIVER_A8XX)); /** Overrides the pick, for a device we cannot reach: a7xx, a8xx or system. */ private static final String OVERRIDE_FILE = "Download/steamdeck-driver"; + /** Bundled drivers the user deleted: hidden from the list, their unpacked copy removed. */ + private static final String PREFS = "graphics_driver"; + private static final String KEY_HIDDEN = "hiddenBundled"; /** Stored choice meaning "pick by GPU" - the default, and what a removed import falls back to. */ public static final String AUTO = ""; @@ -111,6 +114,37 @@ public final class TurnipDriver { return library != null && !library.isEmpty() && new File(new File(contentDir, driverId), library).isFile(); } + /** + * The bundled drivers the list shows: {@link #BUNDLED} minus the ones the user deleted. A deleted + * one still lives in the apk and Auto still unpacks it on a GPU that needs it, so deleting can + * never leave a device without a driver; it only leaves the list. + */ + public List visibleBundled() { + java.util.Set hidden = hiddenBundled(); + ArrayList out = new ArrayList<>(); + for (String id : BUNDLED) if (!hidden.contains(id)) out.add(id); + return out; + } + + public java.util.Set hiddenBundled() { + return new java.util.HashSet<>(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getStringSet(KEY_HIDDEN, java.util.Collections.emptySet())); + } + + /** "Delete" for a bundled driver: remove its unpacked copy and hide it from the list. */ + public void hideBundled(String driverId) { + if (!BUNDLED.contains(driverId)) return; + FileUtils.delete(new File(contentDir, driverId)); + java.util.Set hidden = hiddenBundled(); + hidden.add(driverId); + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putStringSet(KEY_HIDDEN, hidden).apply(); + Log.i(TAG, "bundled driver " + driverId + " hidden by the user"); + } + + public void restoreBundled() { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_HIDDEN).apply(); + } + /** Imported drivers only - the bundled two are listed by {@link #BUNDLED}. */ public List enumerateImported() { ArrayList ids = new ArrayList<>(); diff --git a/app/src/main/java/com/steamdeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/steamdeck/launcher/gpu/TurnipReleases.kt index 5482d2883..67a546234 100644 --- a/app/src/main/java/com/steamdeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/steamdeck/launcher/gpu/TurnipReleases.kt @@ -146,6 +146,12 @@ object TurnipReleases { fun installedId(context: Context, asset: Asset, isInstalled: (String) -> Boolean): String? = downloads(context).optString(asset.name, "").takeIf { it.isNotEmpty() && isInstalled(it) } + /** True when [id] came from a release download rather than a zip picked by hand. */ + fun isDownloaded(context: Context, id: String): Boolean { + val d = downloads(context) + return d.keys().asSequence().any { d.optString(it) == id } + } + fun recordDownload(context: Context, asset: Asset, id: String) { prefs(context).edit().putString(KEY_DOWNLOADS, downloads(context).put(asset.name, id).toString()).apply() } diff --git a/app/src/main/java/com/steamdeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/steamdeck/launcher/ui/DriverPage.kt new file mode 100644 index 000000000..97ba1c72f --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/ui/DriverPage.kt @@ -0,0 +1,164 @@ +package com.steamdeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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.sp + +/** + * One driver list as a full page - the Runtime driver or the Display driver - opened from the + * session's settings page. Installed drivers are picked by tapping the row and each carries a trash + * can (the "Auto"/"Runtime default" row is a setting, not a driver, so it has none); what the last + * check of the release repos found is offered below; the refresh button beside the title is the + * only thing that goes online. + */ +@Composable +fun DriverPage( + title: String, + hint: String, + rows: List, + selected: String, + downloads: List, + status: String, + checking: Boolean, + importLabel: String, + canRestore: Boolean, + onSelect: (String) -> Unit, + onDelete: (String) -> Unit, + onRefresh: () -> Unit, + onDownload: (String) -> Unit, + onImport: () -> Unit, + onRestore: () -> Unit, + onBack: () -> Unit, +) { + val host = rememberMenuHost() + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + var confirm by remember { mutableStateOf(null) } + SettingsPage( + host, title = title, onBack = onBack, lede = "$hint\n$status", + action = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) + .border(1.dp, pal.line, RoundedCornerShape(10.dp)) + .clickable(enabled = !checking, onClick = onRefresh), + ) { + if (checking) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + else Icon(Icons.Outlined.Refresh, contentDescription = "Check for new drivers", tint = colors.onBackground, modifier = Modifier.size(20.dp)) + } + }, + ) { + SettingsGroup("Installed") { + for (row in rows) InstalledRow(row, row.id == selected, onSelect = { onSelect(row.id) }, onDelete = { confirm = row }) + } + SettingsGroup("Available to download") { + if (downloads.isEmpty()) Text( + "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 downloads) { + SettingsRow(d.label, d.detail) { + val p = d.progress + if (p == null) SecondaryButton("Download") { onDownload(d.key) } + else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(120.dp)) { + Text("$p%", fontSize = 12.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator(progress = { p / 100f }, modifier = Modifier.fillMaxWidth()) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) { + SecondaryButton(importLabel, onClick = onImport) + if (canRestore) Text( + "Restore built-in drivers", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, + modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onRestore).padding(8.dp), + ) + } + } + confirm?.let { row -> + AlertDialog( + onDismissRequest = { confirm = null }, + title = { Text("Delete ${row.name}?") }, + text = { + Text( + if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it." + else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "", + fontSize = 13.sp, + ) + }, + confirmButton = { TextButton(onClick = { confirm = null; onDelete(row.id) }) { Text("Delete") } }, + dismissButton = { TextButton(onClick = { confirm = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit, onDelete: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().background(if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) + .clickable(onClick = onSelect).padding(horizontal = 14.dp, vertical = 11.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(20.dp).border(2.dp, if (selected) pal.signal else colors.onSurfaceVariant, CircleShape), + ) { if (selected) Box(Modifier.size(10.dp).background(pal.signal, CircleShape)) } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + row.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false), + ) + if (row.tag.isNotEmpty()) Text( + row.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp)) + } + if (row.removable) Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).clickable(onClick = onDelete), + ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${row.name}", tint = colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) } + } + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) +} diff --git a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt index 839b497c6..354f09a2e 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt @@ -3,10 +3,6 @@ package com.steamdeck.launcher.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,10 +17,17 @@ import com.steamdeck.launcher.core.FexPreset import com.steamdeck.launcher.session.SessionPrefs import com.steamdeck.launcher.session.SessionService -class DriverRow(val id: String, val name: String, val detail: String, val removable: Boolean) +/** [tag] is shown beside the name: [BUNDLED], [DOWNLOADED], [IMPORTED], or "" for Auto / Runtime default. */ +class DriverRow(val id: String, val name: String, val detail: String, val removable: Boolean, val tag: String = "") { + companion object { + const val BUNDLED = "BUNDLED" + const val DOWNLOADED = "DOWNLOADED" + const val IMPORTED = "IMPORTED" + } +} /** A release driver (Banners-Turnip, WinNative) that is not installed yet; [key] is its asset name. */ -class DownloadRow(val key: String, val label: String, val detail: String) +class DownloadRow(val key: String, val label: String, val detail: String, val progress: Int? = null) class ModeSettings( val mode: String, @@ -59,7 +62,10 @@ class ModeSettings( /** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */ val linuxDownloads: List = emptyList(), val androidDownloads: List = emptyList(), - val releaseStatus: String = "Check for new drivers (Banners-Turnip, WinNative)", + val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers", + val releaseChecking: Boolean = false, + /** A bundled display driver was deleted: the page offers to restore it. */ + val canRestoreBundled: Boolean = false, ) /** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */ @@ -77,6 +83,7 @@ class ModeSettingsActions( val onRefreshReleases: () -> Unit = {}, /** Asset name of the release driver to download. */ val onDownloadDriver: (String) -> Unit = {}, + val onRestoreBundled: () -> Unit = {}, val onSelectAndroid: (String) -> Unit, val onImportAndroid: () -> Unit, val onRemoveAndroid: (String) -> Unit, @@ -103,6 +110,34 @@ class ModeSettingsActions( fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { val steam = s.mode == SessionService.MODE_STEAM val host = rememberMenuHost() + // The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display). + var driverPage by remember { mutableStateOf(null) } + when (driverPage) { + "rt" -> { + DriverPage( + title = "Runtime driver", + hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.", + rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads, + status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false, + onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases, + onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null }, + ) + return + } + "panel" -> { + DriverPage( + title = "Display driver", + hint = "Used by the compositor in both modes. Restart the app to apply.", + rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads, + status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…", + canRestore = s.canRestoreBundled, + onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases, + onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled, + onBack = { driverPage = null }, + ) + return + } + } SettingsPage( host, title = if (steam) "Steam session" else "Desktop session", @@ -140,22 +175,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } SettingsGroup("Drivers") { - DriverRowMenu( - host, "rt", "Runtime driver", - (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.", - s.linuxRows, s.linuxSelected, importLabel = "Import Turnip zip…", - onSelect = a.onSelectLinux, onRemove = a.onRemoveLinux, onImport = a.onImportLinux, - downloads = s.linuxDownloads, releaseStatus = s.releaseStatus, - onRefresh = a.onRefreshReleases, onDownload = a.onDownloadDriver, - ) - DriverRowMenu( - host, "panel", "Display driver", - "Used by the compositor in both modes. Restart the app to apply.", - s.androidRows, s.androidSelected, importLabel = "Import an AdrenoTools zip…", - onSelect = a.onSelectAndroid, onRemove = a.onRemoveAndroid, onImport = a.onImportAndroid, - downloads = s.androidDownloads, releaseStatus = s.releaseStatus, - onRefresh = a.onRefreshReleases, onDownload = a.onDownloadDriver, - ) + SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") { + ValueChip(s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false) { driverPage = "rt" } + } + SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") { + ValueChip(s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false) { driverPage = "panel" } + } } SettingsGroup(if (steam) "Touch & controls" else "Touch") { ChoiceRow( @@ -277,55 +302,6 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { } } -@Composable -private fun DriverRowMenu( - host: MenuHost, key: String, label: String, hint: String, rows: List, selected: String, importLabel: String, - onSelect: (String) -> Unit, onRemove: (String) -> Unit, onImport: () -> Unit, - downloads: List = emptyList(), releaseStatus: String? = null, - onRefresh: () -> Unit = {}, onDownload: (String) -> Unit = {}, -) { - val open = host.open == key - val colors = MaterialTheme.colorScheme - var confirmDelete by remember { mutableStateOf(null) } - SettingsRow(label, hint, highlighted = open) { - androidx.compose.foundation.layout.Box { - ValueChip(rows.firstOrNull { it.id == selected }?.name ?: rows.firstOrNull()?.name ?: "-", open) { host.open = if (open) null else key } - AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label) { - for (row in rows) MenuItem( - row.name, checked = row.id == selected, detail = row.detail.ifEmpty { null }, - trailing = if (row.removable) ({ - androidx.compose.material3.Icon( - Icons.Outlined.Delete, contentDescription = "Delete ${row.name}", - tint = colors.onSurfaceVariant, - modifier = Modifier.size(32.dp).clickable { confirmDelete = row }.padding(6.dp), - ) - }) else null, - ) { onSelect(row.id); host.open = null } - // The latest Banners-Turnip release: what is not installed yet, then the refresh line. - // Tapping either keeps the menu open, so the progress and the result show in place. - for (d in downloads) MenuItem( - d.label, checked = false, detail = d.detail, - leading = { androidx.compose.material3.Icon(Icons.Outlined.Download, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) }, - ) { onDownload(d.key) } - if (releaseStatus != null) MenuItem( - releaseStatus, checked = false, - leading = { androidx.compose.material3.Icon(Icons.Outlined.Refresh, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) }, - ) { onRefresh() } - MenuItem(importLabel, checked = false) { host.open = null; onImport() } - } - } - } - confirmDelete?.let { row -> - androidx.compose.material3.AlertDialog( - onDismissRequest = { confirmDelete = null }, - title = { Text("Delete ${row.name}?") }, - text = { Text("Its files are removed from the app. If it is the driver in use, the default takes its place.", fontSize = 13.sp) }, - confirmButton = { androidx.compose.material3.TextButton(onClick = { confirmDelete = null; onRemove(row.id) }) { Text("Delete") } }, - dismissButton = { androidx.compose.material3.TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } }, - ) - } -} - /** The Resolution menu's "Custom…" entry. */ private const val CUSTOM = -1 diff --git a/app/src/main/java/com/steamdeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/steamdeck/launcher/ui/SettingsWidgets.kt index e151f249c..46bc10e90 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/SettingsWidgets.kt @@ -280,6 +280,8 @@ fun SettingsPage( onBack: () -> Unit, eyebrow: String? = null, lede: String? = null, + /** A small control at the right of the title (the driver pages' refresh button). */ + action: (@Composable () -> Unit)? = null, content: @Composable ColumnScope.() -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -297,7 +299,12 @@ fun SettingsPage( } } } - Rise(1) { Title(title) } + Rise(1) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { Title(title) } + if (action != null) action() + } + } if (lede != null) Rise(2) { Lede(lede) } Rise(3, Modifier.weight(1f).fillMaxWidth()) { Column(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = dim }.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { content() }