session drawer in the front end's dress + FEX presets + landscape file manager

The drawer Back opens over a session slides in from the right and uses the same rows and
anchored menus as the settings pages: Now (HUD, frame generation, touch, the virtual pad,
keyboard, the client's menu) and Next session (display shape, FEX preset, compatibility tools),
with Send to background and Stop at the end. Steam-only rows show only in a Steam session.

FEXCore presets, Bannerlator's table carried over (core/FexPreset): a Games row in the Steam
settings page and the drawer; the chosen preset's FEX_* variables go into the Steam session's
environment so the games the client launches inherit them. Default = FEX's own defaults, which
is what every session ran on before.

The file manager and the picker stay in landscape like the rest of the app.
This commit is contained in:
The412Banner committed 2026-09-22 18:44:12 -04:00
1 parent af3f377dfd
commit 63cde17bf4
9 files changed
+237 -172

No files matched your search

+2
View File
@@ -67,11 +67,13 @@
<activity
android:name=".files.FileManagerActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<activity
android:name=".files.FilePickerActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<activity
@@ -118,6 +118,7 @@ class MainActivity : ComponentActivity() {
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolutionCap by mutableStateOf(1080)
private var fexPreset by mutableStateOf("")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
private var hdrReason by mutableStateOf<String?>(null)
@@ -350,6 +351,7 @@ class MainActivity : ComponentActivity() {
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
@@ -378,6 +380,7 @@ class MainActivity : ComponentActivity() {
onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onDismiss = { settingsMode = null },
),
)
@@ -411,6 +414,7 @@ class MainActivity : ComponentActivity() {
private fun openModeSettings(mode: String) {
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
hdrReason = com.steamdeck.launcher.wayland.HdrSupport.probe(this).reason
@@ -44,7 +44,6 @@ import com.steamdeck.launcher.session.SessionService
import com.steamdeck.launcher.session.SessionState
import com.steamdeck.launcher.ui.CursorOverlay
import com.steamdeck.launcher.ui.DrawerActions
import com.steamdeck.launcher.ui.FrameGenDialog
import com.steamdeck.launcher.ui.HudText
import com.steamdeck.launcher.ui.LoadingOverlay
import com.steamdeck.launcher.ui.ProtonDialog
@@ -84,11 +83,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
private var showFrameGen by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
private var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
private var hudOn by mutableStateOf(true)
private var frameGenLabel by mutableStateOf("Off")
private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
private var frameGenMultiplier by mutableStateOf(2)
private var fexPreset by mutableStateOf("")
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
@@ -157,15 +158,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
if (drawerOpen) SessionDrawer(DrawerActions(
hudOn = hudOn, frameGenLabel = frameGenLabel, oscMode = oscMode,
touchMode = when (touchMode) {
SessionPrefs.TOUCH_PAD -> "touchpad"; SessionPrefs.TOUCH_DIRECT -> "direct"
else -> "auto (" + (if (usingTouchpad()) "touchpad" else "direct") + ")"
},
shapeMode = if (shapeMode == SessionPrefs.SHAPE_WIDE) "16:9" else "panel",
SessionDrawer(drawerOpen, DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
hudOn = hudOn,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
oscMode = oscMode, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFrameGen = { showFrameGen = true },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
readPrefs()
applyFrameGen()
},
onKeyboard = { drawerOpen = false; keyboard?.toggle() },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({
// The Guide button, the way the on-screen ◉ sends it: a device with no
@@ -178,31 +184,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, 90)
}) else null,
onProtons = { refreshProtons(); showProtons = true },
onOsc = {
val next = when (SessionPrefs.oscMode(this@SessionActivity)) {
SessionPrefs.OSC_AUTO -> SessionPrefs.OSC_ALWAYS
SessionPrefs.OSC_ALWAYS -> SessionPrefs.OSC_NEVER
else -> SessionPrefs.OSC_AUTO
}
SessionPrefs.setOscMode(this@SessionActivity, next)
readPrefs()
updateOnScreenControls()
},
onTouch = {
val next = when (SessionPrefs.touchMode(this@SessionActivity)) {
SessionPrefs.TOUCH_AUTO -> SessionPrefs.TOUCH_PAD
SessionPrefs.TOUCH_PAD -> SessionPrefs.TOUCH_DIRECT
else -> SessionPrefs.TOUCH_AUTO
}
SessionPrefs.setTouchMode(this@SessionActivity, next)
readPrefs()
},
onShape = {
val next = if (SessionPrefs.shapeMode(this@SessionActivity) == SessionPrefs.SHAPE_WIDE)
SessionPrefs.SHAPE_AUTO else SessionPrefs.SHAPE_WIDE
SessionPrefs.setShapeMode(this@SessionActivity, next)
readPrefs()
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
@@ -214,18 +199,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onRemove = { id -> ProtonExtras.tools.first { it.id == id }.let { ProtonExtras.remove(this@SessionActivity, it) }; refreshProtons() },
onDismiss = { showProtons = false },
)
if (showFrameGen) FrameGenDialog(
engine = FrameGen.engine(this@SessionActivity),
multiplier = FrameGen.multiplier(this@SessionActivity),
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
onPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
showFrameGen = false
readPrefs()
applyFrameGen()
},
onDismiss = { showFrameGen = false },
)
}
}
})
@@ -265,6 +238,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
hudOn = SessionPrefs.hudEnabled(this)
touchMode = SessionPrefs.touchMode(this)
frameGenLabel = FrameGen.label(this)
frameGenEngine = FrameGen.engine(this)
frameGenMultiplier = FrameGen.multiplier(this)
fexPreset = SessionPrefs.fexPreset(this)
oscMode = SessionPrefs.oscMode(this)
shapeMode = SessionPrefs.shapeMode(this)
}
@@ -0,0 +1,42 @@
package com.steamdeck.launcher.core
/**
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
* session's environment; the games under FEX inherit them from the client. The default is FEX's
* own defaults - no variables at all - which is what every session has run on so far.
*
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
*/
object FexPreset {
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
)
val all: List<Preset> = listOf(
Preset("", "FEX defaults", "No variables set; what every session ran on until now.", emptyList()),
Preset("STABILITY", "Stability", "Every TSO emulation on, single-block: the slowest and the surest.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", "Compatibility", "Every TSO emulation on, multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", "Intermediate", "Main and half-barrier TSO on, vector and memcpy off, reduced x87.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", "Performance", "All TSO off, reduced x87, multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance with the main store ordering kept on, for the many games that need it.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", "Extreme", "Performance plus no self-modifying-code checks and FEX's block caches retuned. JIT-heavy titles and DRM can break.",
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme with the main store ordering kept on.",
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_GN", "Extreme-gn", "Performance plus the small TSC scale and volatile-metadata hints; SMC checks stay on. Try before Extreme.",
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
Preset("DENUVO", "Denuvo", "Performance with full SMC checks and the hypervisor bit hidden, for titles whose anti-tamper looks for either.",
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
)
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
fun env(id: String): List<String> = byId(id).env
}
@@ -194,6 +194,13 @@ object SessionPrefs {
*/
fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode))
/** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: ""
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
}
/** What a mode gets when nothing was chosen. */
@Suppress("UNUSED_PARAMETER")
fun defaultResolutionCap(mode: String): Int = 720
@@ -199,6 +199,15 @@ class SessionService : Service() {
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
// here, before the script, so every game process inherits them from the client. The
// default preset sets nothing, which is what every session ran on before.
if (SessionState.mode == MODE_STEAM) {
val preset = SessionPrefs.fexPreset(this)
val vars = com.steamdeck.launcher.core.FexPreset.env(preset)
vars.forEach { guest.add(it) }
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
}
// Anything else, for a device that cannot be reached with a debugger: Downloads/steamdeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
@@ -1,67 +0,0 @@
package com.steamdeck.launcher.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.steamdeck.launcher.gpu.FrameGen
/**
* Off, or an engine at 2x/3x/4x. The LSFG rows are dimmed and unpickable until Lossless Scaling
* is installed in the Steam client — the engine cannot run without its DLL, so offering it would
* only produce a setting that quietly does nothing.
*/
@Composable
fun FrameGenDialog(
engine: String,
multiplier: Int,
lsfgReady: Boolean,
onPick: (engine: String, multiplier: Int) -> Unit,
onDismiss: () -> Unit,
) {
data class Row(val label: String, val engine: String, val multiplier: Int, val enabled: Boolean)
val note = if (lsfgReady) "" else " — install Lossless Scaling in Steam"
val rows = listOf(Row("Off", FrameGen.ENGINE_OFF, 2, true)) +
(2..4).map { Row("Win-FG ${it}×", FrameGen.ENGINE_WINFG, it, true) } +
(2..4).map { Row("LSFG ${it}×$note", FrameGen.ENGINE_LSFG, it, lsfgReady) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Frame generation") },
text = {
Column {
Text(
"Extra frames are generated between the real ones on the way to the screen, so a game running at 30 fps looks like 60. Takes effect at once, mid-game included.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
for (row in rows) {
val selected = row.engine == engine && (row.engine == FrameGen.ENGINE_OFF || row.multiplier == multiplier)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.alpha(if (row.enabled) 1f else 0.38f)
.clickable(enabled = row.enabled) { onPick(row.engine, row.multiplier) }
.padding(vertical = 2.dp),
) {
RadioButton(selected = selected, onClick = null, enabled = row.enabled)
Text(row.label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 4.dp))
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
)
}
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.steamdeck.launcher.core.FexPreset
import com.steamdeck.launcher.session.SessionPrefs
import com.steamdeck.launcher.session.SessionService
@@ -39,6 +40,8 @@ class ModeSettings(
/** Steam only: the second library's root ("" = internal only) and what this device offers. */
val gameStorage: String? = null,
val storageOptions: List<Pair<String, String>> = emptyList(),
/** Steam only: the FEXCore preset for the games the client launches. */
val fexPreset: String? = null,
)
class ModeSettingsActions(
@@ -58,6 +61,7 @@ class ModeSettingsActions(
val onRenderer: (String) -> Unit,
val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> },
val onPickGameStorageFolder: () -> Unit = {},
val onFexPreset: (String) -> Unit = {},
val onDismiss: () -> Unit,
)
@@ -128,6 +132,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = "Auto shows it when no controller is attached.", onPick = a.onOsc,
)
}
if (steam && s.fexPreset != null) SettingsGroup("Games") {
ChoiceRow(
host, "fex", "FEX preset", "How FEX translates the x86 games the client launches. Applies to the next game launch. Also in the session's drawer.",
FexPreset.all.map { it.id to it.label }, s.fexPreset,
note = FexPreset.byId(s.fexPreset).detail, onPick = a.onFexPreset,
)
}
if (steam && s.directAudio != null && s.mic != null) SettingsGroup("Audio") {
ToggleRow(host, "da", "DirectAudio for games", "Games play straight to the device, bypassing PulseAudio: lower latency. Off = PulseAudio for everything.", s.directAudio, onChange = a.onDirectAudio)
ToggleRow(host, "mic", "Microphone", "The device's microphone for voice chat, as the client's input device. Asks for the permission once.", s.mic, onChange = a.onMic)
@@ -33,6 +33,31 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.steamdeck.launcher.R
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import com.steamdeck.launcher.core.FexPreset
import com.steamdeck.launcher.gpu.FrameGen
import com.steamdeck.launcher.session.SessionPrefs
/** A line of numbers in the top-right corner. It takes no touches: everything goes to the game. */
@Composable
@@ -86,83 +111,139 @@ fun LoadingOverlay(step: String, percent: Int, elapsed: String, hint: String, en
/** Everything the drawer shows and does. */
class DrawerActions(
val steam: Boolean,
val hudOn: Boolean,
val frameGenLabel: String,
val frameGenEngine: String,
val frameGenMultiplier: Int,
val lsfgReady: Boolean,
val oscMode: String,
val touchMode: String,
/** What "auto" resolves to right now: "touchpad" or "direct". */
val touchAuto: String,
val shapeMode: String,
val fexPreset: String,
val onHud: (Boolean) -> Unit,
val onFrameGen: () -> Unit,
val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
val onKeyboard: () -> Unit,
/** Sends the Guide button (the client's menu); null on the desktop, where there is none. */
val onSteamMenu: (() -> Unit)?,
val onProtons: () -> Unit,
val onOsc: () -> Unit,
val onTouch: () -> Unit,
val onShape: () -> Unit,
val onOsc: (String) -> Unit,
val onTouch: (String) -> Unit,
val onShape: (String) -> Unit,
val onFexPreset: (String) -> Unit,
val onBackground: () -> Unit,
val onStop: () -> Unit,
val onClose: () -> Unit,
)
/**
* The drawer Back opens over a running session: the switches a player reaches for mid-game, and
* the two things that leave the session. A tap on the dimmed area closes it.
* The drawer Back opens over a running session, in the front end's own dress: a panel that
* slides in from the right with the switches a player reaches for mid-game as rows whose values
* open in place, what only applies at the next session under its own heading, and the two ways
* out at the end. Steam and the desktop share the rows that mean the same on both; the client's
* own (its menu, the virtual pad, the FEX preset, the compatibility tools) show only there.
*/
@Composable
fun SessionDrawer(a: DrawerActions) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x66000000))
.clickable(interactionSource = MutableInteractionSource(), indication = null, onClick = a.onClose),
contentAlignment = Alignment.CenterEnd,
) {
Column(
modifier = Modifier
.fillMaxHeight()
.width(264.dp)
.background(Color(0xF2151A22))
// The panel swallows its own touches so they do not close it.
.clickable(interactionSource = MutableInteractionSource(), indication = null) {}
// More entries than a landscape phone has height for: the panel scrolls, and the
// two session-leaving actions ride at the end of the list rather than pinned.
.verticalScroll(rememberScrollState())
.padding(16.dp),
fun SessionDrawer(open: Boolean, a: DrawerActions) {
val colors = MaterialTheme.colorScheme
val host = rememberMenuHost()
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
if (open || veil > 0.01f) Box(
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
AnimatedVisibility(
open,
enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)),
exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)),
) {
Text("SteamDeck", color = Color.White, fontSize = 16.sp, modifier = Modifier.padding(bottom = 12.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("Performance HUD", color = Color(0xFFDDDDDD), fontSize = 13.sp, modifier = Modifier.weight(1f))
Switch(checked = a.hudOn, onCheckedChange = a.onHud)
}
OutlinedButton(onClick = a.onKeyboard, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text("Keyboard", fontSize = 13.sp)
}
if (a.onSteamMenu != null) OutlinedButton(onClick = a.onSteamMenu, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Steam menu ◉", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onFrameGen, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Frame generation: ${a.frameGenLabel}", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onProtons, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Compatibility tools", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onOsc, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("On-screen controls: ${a.oscMode}", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onTouch, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Touch: ${a.touchMode}", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onShape, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Display shape: ${a.shapeMode} · next session", fontSize = 13.sp)
}
Spacer(Modifier.height(18.dp))
OutlinedButton(onClick = a.onBackground, modifier = Modifier.fillMaxWidth()) {
Text("Send to background", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onStop, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Stop session", fontSize = 13.sp, color = MaterialTheme.colorScheme.error)
Column(
modifier = Modifier
.fillMaxHeight()
.width(340.dp)
.background(Color(0xF7101418))
// The panel swallows its own touches so they do not close it.
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Brush.linearGradient(listOf(colors.primary, Color(0xFF7B4DFF)))))
Spacer(Modifier.width(10.dp))
Column {
Text("SteamDeck", fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(if (a.steam) "Steam session" else "Desktop session", fontSize = 11.sp, color = colors.onSurfaceVariant)
}
}
SettingsGroup("Now") {
ToggleRow(host, "hud", "Performance HUD", "The frame counter in the corner.", a.hudOn, onChange = a.onHud)
val fgOpen = host.open == "fg"
val fgLabel = when (a.frameGenEngine) {
FrameGen.ENGINE_WINFG -> "Win-FG ${a.frameGenMultiplier}×"
FrameGen.ENGINE_LSFG -> "LSFG ${a.frameGenMultiplier}×"
else -> "Off"
}
SettingsRow("Frame generation", "Extra frames between the real ones; takes effect at once, mid-game included.", highlighted = fgOpen) {
Box {
ValueChip(fgLabel, fgOpen) { host.open = if (fgOpen) null else "fg" }
AnchoredMenu(fgOpen, onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") {
val need = if (a.lsfgReady) null else "install Lossless Scaling in Steam"
MenuItem("Off", checked = a.frameGenEngine == FrameGen.ENGINE_OFF) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null }
for (m in 2..4) MenuItem("Win-FG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_WINFG && a.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null }
for (m in 2..4) MenuItem("LSFG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_LSFG && a.frameGenMultiplier == m, enabled = a.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null }
}
}
}
ChoiceRow(
host, "touch", "Touch", "How a finger drives the pointer.",
listOf(SessionPrefs.TOUCH_AUTO to "Auto (${a.touchAuto})", SessionPrefs.TOUCH_PAD to "Touchpad", SessionPrefs.TOUCH_DIRECT to "Direct"), a.touchMode,
note = "Touchpad: drag moves, tap clicks. Direct: the pointer jumps under the finger.", onPick = a.onTouch,
)
if (a.steam) ChoiceRow(
host, "osc", "On-screen controls", "The virtual pad drawn over a game.",
listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_NEVER to "Never"), a.oscMode,
note = "Auto shows it when no controller is attached.", onPick = a.onOsc,
)
ActionRow("Keyboard", "The on-screen keyboard, for a field the client or a program is waiting on.", "Show") { host.open = null; a.onKeyboard() }
if (a.onSteamMenu != null) ActionRow("Steam menu", "The Guide button: the client's own overlay, for a pad without one.", "Open ◉") { host.open = null; a.onSteamMenu.invoke() }
}
SettingsGroup("Next session") {
ChoiceRow(
host, "shape", "Display shape", "gamescope sizes its display once, when a session starts.",
listOf(SessionPrefs.SHAPE_AUTO to "The panel's shape", SessionPrefs.SHAPE_WIDE to "16:9 with bars"), a.shapeMode, onPick = a.onShape,
)
if (a.steam) ChoiceRow(
host, "fex", "FEX preset", "How FEX translates the x86 games the client launches. Applies to the next game launch.",
FexPreset.all.map { it.id to it.label }, a.fexPreset, note = FexPreset.byId(a.fexPreset).detail, onPick = a.onFexPreset,
)
if (a.steam) ActionRow("Compatibility tools", "The Proton builds the client can run games with.", "Manage") { host.open = null; a.onProtons() }
}
Spacer(Modifier.height(18.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Send to background") { host.open = null; a.onBackground() }
DangerButton("Stop session") { host.open = null; a.onStop() }
}
Spacer(Modifier.height(12.dp))
}
}
}
}
/** The one button that ends things: outlined in the error colour, filled on focus. */
@Composable
private fun DangerButton(text: String, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val fill by animateColorAsState(if (hot) colors.error.copy(alpha = 0.18f) else Color.Transparent, Motion.tw(220), label = "dangerFill")
Box(
modifier = Modifier.clip(RoundedCornerShape(12.dp)).background(fill).border(1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.5f), RoundedCornerShape(12.dp))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 11.dp),
) { Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.error, maxLines = 1) }
}