Main screen: a ▶ chip per installed emulator under Desktop, and a ? that explains them

One chip per emulator the desktop packages installed, starting it fullscreen under gamescope;
the ? says what they are, how to use them (ROMs at /root/ROMs, the phone at /root/Storage) and
why they are here rather than on the desktop (labwc on pixman offers no dma-buf).
This commit is contained in:
The412Banner committed 2026-09-22 05:35:18 -04:00
1 parent 785bf23087
commit eb8d6b76c2
2 files changed
+102 -10

No files matched your search

@@ -121,6 +121,8 @@ class MainActivity : ComponentActivity() {
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var renderer by mutableStateOf("pixman")
private var emulators by mutableStateOf<List<Pair<String, String>>>(emptyList())
private var showEmulatorHelp by mutableStateOf(false)
private var romsDir by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
private var showRoms by mutableStateOf(false)
@@ -138,7 +140,7 @@ class MainActivity : ComponentActivity() {
frameGenLabel = frameGenLabel,
desktopInstalled = desktopInstalled,
offlineAccount = offlineAccount, offline = offline,
romsDir = romsDir, logsEnabled = logsEnabled,
romsDir = romsDir, logsEnabled = logsEnabled, emulators = emulators,
),
onPlay = { startActivity(Intent(this, SessionActivity::class.java)) },
onDesktop = {
@@ -158,6 +160,8 @@ class MainActivity : ComponentActivity() {
onPerformance = { refreshCores(); showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.steamdeck.launcher.files.FileManagerActivity::class.java)) },
onLaunchEmulator = { path -> launchProgram(path) },
onEmulatorHelp = { showEmulatorHelp = true },
onLogs = {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
logsEnabled = SessionPrefs.logsEnabled(this)
@@ -177,12 +181,7 @@ class MainActivity : ComponentActivity() {
rows = packageRows, busyStage = pkgStage, busyPercent = pkgPercent,
onInstall = { id -> installPackage(id) },
onRemove = { id -> catalog?.firstOrNull { it.id == id }?.let { DesktopCatalog.remove(this, it) }; refreshPackages() },
onLaunch = { path ->
showApps = false
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, path))
},
onLaunch = { path -> showApps = false; launchProgram(path) },
onDismiss = { showApps = false },
)
if (showProtons) ProtonDialog(
@@ -271,6 +270,7 @@ class MainActivity : ComponentActivity() {
onDismiss = { showFrameGen = false },
)
if (showCredits) CreditsDialog { showCredits = false }
if (showEmulatorHelp) com.steamdeck.launcher.ui.EmulatorHelpDialog { showEmulatorHelp = false }
}
}
@@ -305,15 +305,39 @@ class MainActivity : ComponentActivity() {
DesktopCatalog.installed(this, it.id), launchersOf(it))
}
desktopInstalled = DesktopCatalog.desktopInstalled(this)
emulators = installedEmulators()
}
/** What a package can start on its own under gamescope; paths inside the runtime. */
private fun launchersOf(entry: DesktopCatalog.Entry): List<Pair<String, String>> = when {
entry.kind == "appimage" -> listOf(entry.name.substringBefore(" (").trim() to "/opt/appimages/${entry.id}.AppImage")
entry.id == "emulators" -> listOf("PPSSPP" to "/usr/bin/PPSSPPSDL", "RetroArch" to "/usr/bin/retroarch")
private fun launchersOf(entry: DesktopCatalog.Entry): List<Pair<String, String>> = launchersOf(entry.id)
/**
* The same, by package id alone, so the main screen's row is known from the install markers
* before (or without) the catalog. The AppImage packages launch as /opt/appimages/<id>.AppImage.
*/
private fun launchersOf(id: String): List<Pair<String, String>> = when (id) {
"emulators" -> listOf("PPSSPP" to "/usr/bin/PPSSPPSDL", "RetroArch" to "/usr/bin/retroarch")
"rpcs3" -> listOf("RPCS3" to "/opt/appimages/rpcs3.AppImage")
"pcsx2" -> listOf("PCSX2" to "/opt/appimages/pcsx2.AppImage")
"dolphin" -> listOf("Dolphin" to "/opt/appimages/dolphin.AppImage")
"duckstation" -> listOf("DuckStation" to "/opt/appimages/duckstation.AppImage")
"melonds" -> listOf("melonDS" to "/opt/appimages/melonds.AppImage")
"cemu" -> listOf("Cemu" to "/opt/appimages/cemu.AppImage")
else -> emptyList()
}
/** Every installed emulator's launcher, for the main screen. */
private fun installedEmulators(): List<Pair<String, String>> =
listOf("rpcs3", "pcsx2", "dolphin", "duckstation", "melonds", "cemu", "emulators")
.filter { DesktopCatalog.installed(this, it) != null }
.flatMap { launchersOf(it) }
private fun launchProgram(path: String) {
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, path))
}
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (pkgStage != null) return
@@ -444,6 +468,7 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
emulators = if (ready) installedEmulators() else emptyList()
}
private fun onRuntimeButton() {
@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -57,6 +59,8 @@ class MainUiState(
/** The folder every session shows at /root/ROMs, or null if none is chosen. */
val romsDir: String?,
val logsEnabled: Boolean,
/** Installed emulators that can run under gamescope: label to path inside the runtime. */
val emulators: List<Pair<String, String>> = emptyList(),
)
/**
@@ -72,6 +76,7 @@ class MainUiState(
* setting's current value under it, so the menu is read at a glance and fits without scrolling
* on a handheld.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MainScreen(
state: MainUiState,
@@ -87,6 +92,8 @@ fun MainScreen(
onOffline: () -> Unit,
onRoms: () -> Unit,
onFiles: () -> Unit,
onLaunchEmulator: (path: String) -> Unit,
onEmulatorHelp: () -> Unit,
onLogs: () -> Unit,
onCredits: () -> Unit,
) {
@@ -168,6 +175,28 @@ fun MainScreen(
) { Text(if (state.desktopInstalled) "Desktop" else "Desktop — install it under Desktop & apps", fontSize = 13.sp * k, maxLines = 1, overflow = TextOverflow.Ellipsis) }
CogButton(k, onDesktopSettings)
}
// Every installed emulator, as a session of its own under gamescope (where the GPU
// is); the ? says why they are here and not on the desktop.
if (state.emulators.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp * k),
verticalArrangement = Arrangement.spacedBy(4.dp * k),
modifier = Modifier.fillMaxWidth().padding(top = 5.dp * k),
) {
for ((label, path) in state.emulators) {
OutlinedButton(
onClick = { onLaunchEmulator(path) }, enabled = state.ready && !state.busy,
contentPadding = PaddingValues(horizontal = 10.dp * k, vertical = 0.dp),
modifier = Modifier.height(30.dp * k),
) { Text("▶ $label", fontSize = 11.sp * k, maxLines = 1) }
}
OutlinedButton(
onClick = onEmulatorHelp,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(30.dp * k).height(30.dp * k),
) { Text("?", fontSize = 12.sp * k) }
}
}
// Offline is read while the client starts, so it is decided here rather than in the
// session's drawer, and it needs credentials from an earlier sign-in to be possible.
OutlinedButton(
@@ -265,6 +294,44 @@ private fun MenuTile(
}
}
/** The ? beside the emulator row: what the ▶ buttons are, how to use them, and why. */
@Composable
fun EmulatorHelpDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Emulators") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("What the ▶ buttons are", style = MaterialTheme.typography.titleSmall)
Text(
"One per emulator installed under Desktop & apps. Each starts that emulator fullscreen, as a " +
"session of its own under gamescope — the same way the Steam client runs — with the Steam " +
"cog's display, driver and HDR settings.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(8.dp))
Text("How to use them", style = MaterialTheme.typography.titleSmall)
Text(
"Put your games in the ROMs folder (main screen). In the emulator, open root › ROMs " +
"(/root/ROMs); the whole phone is at root › Storage. Firmware and BIOS files go in the " +
"same way (RPCS3: File › Install Firmware). Back opens the drawer; Stop session ends it.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(8.dp))
Text("Why not from the desktop", style = MaterialTheme.typography.titleSmall)
Text(
"The desktop draws in software (labwc on pixman) and cannot hand a program the GPU: a Vulkan " +
"emulator started there dies at its first frame (\"Surface lost\"). gamescope can, so an " +
"emulator that renders with Vulkan or OpenGL belongs here. The desktop stays for files, " +
"Firefox and anything that draws in software.",
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
)
}
@Composable
fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(