Driver selection as a full page with refresh and delete for every driver

Runtime driver and Display driver open a full page instead of a
drop-down, built from the settings widgets (as previewed): INSTALLED
lists every driver as a tappable row with a radio mark and a tag
(BUNDLED / DOWNLOADED / IMPORTED); AVAILABLE TO DOWNLOAD lists what the
last check of Banners-Turnip and WinNative found, with a Download button
and progress; Import stays at the bottom. The refresh button sits beside
the title (SettingsPage gains an action slot) and is the only thing that
goes online; it spins while checking.

Every driver gets a trash can with a confirmation, the bundled ones too:
deleting a bundled display driver removes its unpacked copy and hides it
(TurnipDriver.hideBundled), with 'Restore built-in drivers' to bring
them back. It stays in the apk and Auto still unpacks it on a GPU that
needs it, so a deletion can never leave a device without a driver. Auto
and Runtime default are settings, not drivers, and have no trash can.
This commit is contained in:
The412Banner committed 2026-09-23 16:33:16 -04:00
1 parent 97d74e3748
commit 2cd88855f4
6 files changed
+277 -87

No files matched your search

@@ -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<List<com.steamdeck.launcher.ui.DownloadRow>>(emptyList())
private var androidDownloads by mutableStateOf<List<com.steamdeck.launcher.ui.DownloadRow>>(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<String, Int>()
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()
}
@@ -52,6 +52,9 @@ public final class TurnipDriver {
public static final List<String> 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<String> visibleBundled() {
java.util.Set<String> hidden = hiddenBundled();
ArrayList<String> out = new ArrayList<>();
for (String id : BUNDLED) if (!hidden.contains(id)) out.add(id);
return out;
}
public java.util.Set<String> hiddenBundled() {
return new java.util.HashSet<>(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getStringSet(KEY_HIDDEN, java.util.Collections.<String>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<String> 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<String> enumerateImported() {
ArrayList<String> ids = new ArrayList<>();
@@ -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()
}
@@ -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<DriverRow>,
selected: String,
downloads: List<DownloadRow>,
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<DriverRow?>(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))
}
@@ -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<DownloadRow> = emptyList(),
val androidDownloads: List<DownloadRow> = 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<String?>(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<DriverRow>, selected: String, importLabel: String,
onSelect: (String) -> Unit, onRemove: (String) -> Unit, onImport: () -> Unit,
downloads: List<DownloadRow> = emptyList(), releaseStatus: String? = null,
onRefresh: () -> Unit = {}, onDownload: (String) -> Unit = {},
) {
val open = host.open == key
val colors = MaterialTheme.colorScheme
var confirmDelete by remember { mutableStateOf<DriverRow?>(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
@@ -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() }