Clarify display settings and organize in-session controls (#247)

* Clarify display sizing, game fit and scaling controls

* Replace aspect and resolution controls with one dimension menu

* Organize Steam session settings into tabs

* Keep session resolution out of the live controls panel

* Group live session controls and add a conditional second-screen tab

* Refine session sliders, tab icons and contextual controls

* Simplify screen effects with presets and collapsed advanced controls

* Give in-session tab icons more spacing

* Center the loading cancel button glyph
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-04 23:32:30 -04:00
1 parent 4c29c6a61e
commit cb9038b6a0
16 files changed
+831 -427

No files matched your search

@@ -306,13 +306,11 @@ 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 customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var upscaler by mutableStateOf(0)
@@ -1119,7 +1117,8 @@ class MainActivity : ComponentActivity() {
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
gpuDrivers = drivers.summary(),
@@ -1175,9 +1174,7 @@ class MainActivity : ComponentActivity() {
}
},
onWifiDiscoverySettings = { openWifiDiscoverySettings() },
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
@@ -1333,8 +1330,7 @@ class MainActivity : ComponentActivity() {
showProtons = false
showComponents = false
showMapping = false
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
@@ -1346,7 +1342,6 @@ class MainActivity : ComponentActivity() {
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
upscaler = SessionPrefs.upscaler(this)
@@ -83,7 +83,9 @@ import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.DRAWER_PAGES
import com.droiddeck.launcher.ui.SessionDrawerPage
import com.droiddeck.launcher.ui.sessionDrawerPages
import com.droiddeck.launcher.ui.step
import kotlin.math.abs
/**
@@ -206,7 +208,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var closeAtOnce = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
@@ -226,7 +228,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var fexPreset by mutableStateOf("")
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var onScreenButtonsVisible by mutableStateOf(false)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
@@ -475,9 +477,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
@@ -529,7 +531,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
@@ -757,7 +758,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
oscMode = SessionPrefs.oscMode(this)
shapeMode = SessionPrefs.shapeMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
}
@@ -1013,38 +1013,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun outputSize(): Pair<Int, Int> {
val bounds = panelBounds()
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
// square display draws for the frame it was made for and cuts the sides off itself.
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
// compositor letterboxes onto a squarer panel.
// "Match screen" drops that floor, for a 4:3 or 3:2 handheld whose games should
// fill it.
val aspect = when (SessionPrefs.shapeMode(this)) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
// the cap or lift it to the panel.
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
// A custom resolution is taken as given; the compositor fits it to the panel.
SessionPrefs.customResolution(this, mode)?.let { return it }
// An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel,
// unless the user chose a resolution for the mode.
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0
else SessionPrefs.resolutionCap(this, mode)
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
val width = (height * aspect).toInt()
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
return Pair(width and 1.inv(), height and 1.inv())
// Lightweight emulators keep their panel-sized default until a resolution is chosen.
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode))
com.droiddeck.launcher.session.SessionDisplay.MATCH_SCREEN
else SessionPrefs.resolutionChoice(this, mode, panel)
return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, choice)
}
/**
@@ -1262,7 +1239,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty())
.step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
releaseDrawerDirection()
}
return true
@@ -1715,6 +1693,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
return
@@ -1730,6 +1709,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
@@ -108,9 +108,9 @@ object DeviceReport {
h("Display")
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
k("Shape setting", SessionPrefs.shapeMode(context))
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
k("Resolution (Steam)", SessionPrefs.resolutionChoice(context, SessionService.MODE_STEAM, panelSize))
k("Resolution (Desktop)", SessionPrefs.resolutionChoice(context, SessionService.MODE_DESKTOP, panelSize))
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.view.WindowManager
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
object SessionDisplay {
const val MATCH_SCREEN = "screen"
const val DEFAULT_RESOLUTION = "1280x720"
fun screenSize(panel: Pair<Int, Int>): Pair<Int, Int> =
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> =
if (choice == MATCH_SCREEN) screenSize(panel) else {
// Legacy displays may exceed the custom dialog's size limit. Keep their exact size.
val parts = choice.split('x').map { it.toIntOrNull() }
if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
else 1280 to 720
}
/** Fixed dimensions and the current panel, with no duplicate panel-size preset. */
fun resolutionOptions(panel: Pair<Int, Int>): List<String> =
listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080")
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
fun panelSize(context: Context): Pair<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
val mode = manager.defaultDisplay.mode
return maxOf(mode.physicalWidth, mode.physicalHeight) to minOf(mode.physicalWidth, mode.physicalHeight)
}
/** Interpret an older cap/aspect combination without altering the saved picture size. */
fun resolve(panel: Pair<Int, Int>, cap: Int, shape: String, custom: Pair<Int, Int>? = null): Pair<Int, Int> {
custom?.let { return it }
val panelW = maxOf(panel.first, panel.second).toFloat()
val panelH = minOf(panel.first, panel.second).toFloat().coerceAtLeast(1f)
// Auto retains wide panels but floors squarer ones at 16:9 for game compatibility.
// Match screen removes that floor; fixed 16:9 also keeps foldable sessions stable.
val aspect = when (shape) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
return ((height * aspect).toInt() and 1.inv()) to (height and 1.inv())
}
/** Mirrors gamescope's even-width 16:9 calculation, including near-16:9 rounding. */
fun canStretch16x9(size: Pair<Int, Int>): Boolean =
(size.second * 16 + 4) / 9 / 2 * 2 > size.first
}
@@ -88,27 +88,16 @@ object SessionPrefs {
const val SHAPE_WIDE = "16:9"
const val SHAPE_EXACT = "exact"
/** The choices the settings offer, in order. */
val shapeChoices = listOf(
SHAPE_AUTO to "Auto (16:9+)",
SHAPE_EXACT to "Match screen",
SHAPE_WIDE to "Always 16:9",
)
/**
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
* display cannot change size once the session is up.
*/
fun shapeMode(context: Context): String =
private fun shapeMode(context: Context): String =
prefs(context).getString("shape", null)
?: if (context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) SHAPE_WIDE else SHAPE_AUTO
fun setShapeMode(context: Context, mode: String) {
prefs(context).edit().putString("shape", mode).apply()
}
fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO
fun setOscMode(context: Context, mode: String) {
@@ -512,11 +501,33 @@ object SessionPrefs {
* and the emulators under it get the same GPU headroom. Read once, when the session's display
* is sized; a cap the user chose wins over the default.
*/
fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode))
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
/** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */
fun resolutionChosen(context: Context, mode: String): Boolean =
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
prefs(context).contains("displayResolution.$mode") ||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
/** One per-mode choice now owns both dimensions; old caps/shapes are read only for migration. */
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
val saved = prefs(context)
saved.getString("displayResolution.$mode", null)?.let { value ->
if (value == SessionDisplay.MATCH_SCREEN) return value
parseResolution(value)?.let { return "${it.first}x${it.second}" }
}
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
val legacy = SessionDisplay.resolve(panel, resolutionCap(context, mode), shapeMode(context), customResolution(context, mode))
return if (legacy == SessionDisplay.screenSize(panel)) SessionDisplay.MATCH_SCREEN
else "${legacy.first}x${legacy.second}"
}
fun setResolutionChoice(context: Context, mode: String, choice: String) {
val value = if (choice == SessionDisplay.MATCH_SCREEN) choice else {
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
"${size.first}x${size.second}"
}
prefs(context).edit().putString("displayResolution.$mode", value).apply()
}
/**
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
@@ -596,25 +607,13 @@ object SessionPrefs {
prefs(context).edit().putString("theme", id).apply()
}
/** What a mode gets when nothing was chosen. */
@Suppress("UNUSED_PARAMETER")
fun defaultResolutionCap(mode: String): Int = 720
fun setResolutionCap(context: Context, mode: String, cap: Int) {
prefs(context).edit().putInt("resolutionCap.$mode", cap).apply()
}
/**
* A fixed size for the session's display, per mode, or null. When set it replaces both the
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
*/
fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
private fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
parseResolution(prefs(context).getString("customRes.$mode", null))
fun setCustomResolution(context: Context, mode: String, size: Pair<Int, Int>?) {
prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply()
}
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
fun parseResolution(text: String?): Pair<Int, Int>? {
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
@@ -669,20 +668,27 @@ object SessionPrefs {
/**
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
* modes): Off and Linear both filter bilinearly, Nearest keeps pixels square for 2D and old
* titles, the rest sharpen where the picture is enlarged; FSR (fit) rounds the picture to
* FSR's preferred size first. Sharpen only works at any size.
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
* when enlarged; Sharpen only works at any size. The old Off/Linear and FSR/FSR Fit pairs
* are equivalent on Wayland, so saved aliases resolve to one choice.
*/
val upscalerChoices = listOf(
0 to "Off", 1 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 5 to "AMD FSR 1 (fit)", 3 to "Snapdragon GSR",
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
)
fun upscaler(context: Context): Int =
prefs(context).getInt("upscaler", 0).takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
fun canonicalUpscaler(mode: Int): Int = when (mode) {
1 -> 0
5 -> 4
else -> mode.takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
}
fun upscalerHasSharpness(mode: Int): Boolean = canonicalUpscaler(mode) in 3..8
fun upscaler(context: Context): Int = canonicalUpscaler(prefs(context).getInt("upscaler", 0))
fun setUpscaler(context: Context, mode: Int) {
prefs(context).edit().putInt("upscaler", mode).apply()
prefs(context).edit().putInt("upscaler", canonicalUpscaler(mode)).apply()
}
fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100)
@@ -52,13 +52,13 @@ import kotlinx.coroutines.withContext
// dialog, with each variable's actions in a menu off its value as everywhere else.
@Composable
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
var open by remember { mutableStateOf(false) }
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
// would otherwise give the pad nothing to move from.
var byPad by remember { mutableStateOf(false) }
val inputMode = LocalInputModeManager.current
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
SettingsRow(stringResource(R.string.game_env_title), if (showHint) stringResource(R.string.game_env_hint) else null) {
SecondaryButton(stringResource(R.string.game_env_edit), modifier = modifier) {
byPad = inputMode.inputMode == InputMode.Keyboard
open = true
@@ -43,10 +43,8 @@ class DownloadRow(val key: String, val label: String, val detail: String, val pr
class ModeSettings(
val mode: String,
val resolutionCap: Int,
/** A fixed session size, or null for the cap and shape. */
val customResolution: Pair<Int, Int>? = null,
val shapeMode: String,
val resolution: String,
val panelSize: Pair<Int, Int> = 1280 to 720,
val hdr: Boolean,
val hdrReason: String?,
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
@@ -74,7 +72,7 @@ class ModeSettings(
val fexPreset: String? = null,
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
val syncBackend: String? = null,
/** Steam only: games are stretched to fill the screen (null = not a Steam page). */
/** Steam only: forces game windows fullscreen (null = not a Steam page). */
val forceFullscreen: Boolean? = null,
val stretch16x9: Boolean? = null,
/** Steam only: the client branch forced on the command line. */
@@ -113,10 +111,7 @@ class ModeSettings(
class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
class ModeSettingsActions(
val onResolution: (Int) -> Unit,
/** Null clears it. */
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
val onShape: (String) -> Unit,
val onResolution: (String) -> Unit,
val onHdr: (Boolean) -> Unit,
/** Opens the GPU drivers on the Components page: they are shared by every session. */
val onGpuDrivers: () -> Unit = {},
@@ -160,139 +155,156 @@ class ModeSettingsActions(
val onDismiss: () -> Unit,
)
private enum class SteamSettingsTab(val label: Int) {
DISPLAY(R.string.drawer_page_display),
CONTROLS(R.string.drawer_page_controls),
GAMES(R.string.game_settings_title),
AUDIO(R.string.mode_audio),
SESSION(R.string.mode_session),
STEAM(R.string.mode_tab_steam),
}
@Composable
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
val pageScroll = androidx.compose.foundation.rememberScrollState()
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
val tabs = SteamSettingsTab.entries
val tab = tabs[tabIndex]
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
val tabFocus = remember { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
androidx.compose.runtime.LaunchedEffect(Unit) {
// One frame first: the box has to be laid out before it can take focus. Opened from the
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
fun selectTab(index: Int) { host.open = null; tabIndex = index }
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
androidx.compose.runtime.withFrameNanos { }
runCatching { firstChip.requestFocus() }
runCatching {
if (!steam || tab == SteamSettingsTab.DISPLAY) firstChip.requestFocus()
else tabFocus[tabIndex].requestFocus()
}
}
SettingsPage(
host,
title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title),
onBack = a.onDismiss,
scroll = pageScroll,
scroll = scrolls[tabIndex],
modifier = if (steam) Modifier.bumpers(
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
onNext = { selectTab((tabIndex + 1) % tabs.size) },
) else Modifier,
header = if (steam) ({
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp), focusRequesters = tabFocus)
}) else null,
) {
SettingsGroup(stringResource(R.string.mode_display)) {
val default = SessionPrefs.defaultResolutionCap(s.mode)
var editCustom by remember { mutableStateOf(false) }
val custom = s.customResolution
ChoiceRow(
host, "res", stringResource(R.string.mode_resolution), stringResource(R.string.common_applies_next_session),
listOf(720 to stringResource(R.string.mode_res_720), 900 to stringResource(R.string.mode_res_900), 1080 to stringResource(R.string.mode_res_1080), 0 to stringResource(R.string.mode_res_native))
.map { (cap, label) -> cap to (if (cap == default) stringResource(R.string.mode_res_default, label) else label) } +
(CUSTOM to (custom?.let { stringResource(R.string.mode_res_custom_value, it.first, it.second) } ?: stringResource(R.string.mode_res_custom))),
if (custom != null) CUSTOM else s.resolutionCap, note = stringResource(R.string.mode_res_note),
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
)
ChoiceRow(
host, "shape", stringResource(R.string.mode_ratio),
if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto),
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
ChoiceRow(
host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
note = stringResource(R.string.mode_fps_note),
onPick = a.onFpsLimit,
)
ChoiceRow(
host, "upscaler", stringResource(R.string.drawer_scaling), "How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.",
com.droiddeck.launcher.session.SessionPrefs.upscalerChoices, s.upscaler,
note = "The sharpening modes work only when the session is smaller than the screen; Linear, Nearest and Sharpen only work at any size. Costs a little GPU time.",
onPick = a.onUpscaler,
)
SliderRow(
stringResource(R.string.drawer_scaling_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
enabled = s.upscaler != 0, format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
onDismiss = { editCustom = false },
)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
if (!steam || tab == SteamSettingsTab.DISPLAY) {
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
chipModifier = Modifier.focusRequester(firstChip))
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
}
}
}
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
if (!steam || tab == SteamSettingsTab.CONTROLS) {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
),
s.suspendPolicy,
note = stringResource(R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (!steam || tab == SteamSettingsTab.SESSION) {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
),
s.suspendPolicy,
note = stringResource(R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
if (steam) SettingsGroup(stringResource(R.string.mode_decky)) {
if (steam && tab == SteamSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
@@ -351,7 +363,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (steam && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
if (steam && tab == SteamSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "steamdeck", stringResource(R.string.mode_deck_mode),
stringResource(R.string.mode_deck_mode_hint),
@@ -377,7 +389,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (steam && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
if (steam && tab == SteamSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
@@ -396,7 +408,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
}
}
if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
if ((!steam || tab == SteamSettingsTab.GAMES) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
stringResource(R.string.display_force_fullscreen_hint), s.forceFullscreen, onChange = a.onForceFullscreen)
}
if (steam && tab == SteamSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
for (dir in s.addedGamesDirs) {
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
ActionRow(
@@ -421,7 +437,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
}
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (steam && tab == SteamSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (s.syncBackend != null) SettingsRow(
stringResource(R.string.sync_backend_title),
stringResource(R.string.sync_backend_hint),
@@ -442,18 +458,9 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
GameEnvironmentRow()
if (s.forceFullscreen != null) ToggleRow(
host, "fill", stringResource(R.string.mode_fill),
stringResource(R.string.mode_fill_hint),
s.forceFullscreen, onChange = a.onForceFullscreen,
)
if (s.stretch16x9 != null) ToggleRow(
host, "stretch169", stringResource(R.string.mode_stretch169),
stringResource(R.string.mode_stretch169_hint),
s.stretch16x9, onChange = a.onStretch16x9,
)
}
if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
if (steam && tab == SteamSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ChoiceRow(
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
@@ -462,7 +469,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
if (steam && tab == SteamSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
val options = buildList {
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
@@ -531,13 +538,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
/** The Resolution menu's "Custom…" entry. */
private const val CUSTOM = -1
/** Width × height for the session, with the common handheld shapes one tap away. */
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
internal fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
var w by remember { mutableStateOf(initial?.first?.toString() ?: "") }
var h by remember { mutableStateOf(initial?.second?.toString() ?: "") }
val parsed = SessionPrefs.parseResolution("${w}x$h")
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.ui
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.Modifier
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionDisplay
/** Pre-session resolution choice, applied when the session starts. */
@Composable
fun ResolutionRow(
host: MenuHost, choice: String, panel: Pair<Int, Int>, onPick: (String) -> Unit,
chipModifier: Modifier = Modifier,
) {
val custom = "custom"
val size = SessionDisplay.resolveChoice(panel, choice)
val screen = SessionDisplay.screenSize(panel)
val choices = SessionDisplay.resolutionOptions(panel)
val selected = if (size == screen) SessionDisplay.MATCH_SCREEN else choice
var editCustom by remember { mutableStateOf(false) }
val options = choices.map { value ->
val dimensions = SessionDisplay.resolveChoice(panel, value)
value to if (value == SessionDisplay.MATCH_SCREEN) stringResource(R.string.display_match_screen, screen.first, screen.second)
else if (value == SessionDisplay.DEFAULT_RESOLUTION) stringResource(R.string.mode_res_default, "${dimensions.first}×${dimensions.second}")
else "${dimensions.first}×${dimensions.second}"
} + (custom to if (selected in choices) stringResource(R.string.mode_res_custom)
else stringResource(R.string.mode_res_custom_value, size.first, size.second))
ChoiceRow(host, "resolution", stringResource(R.string.display_resolution),
stringResource(R.string.common_applies_next_session),
options, if (selected in choices) selected else custom, chipModifier = chipModifier,
onPick = { value -> if (value == custom) editCustom = true else onPick(value) })
if (editCustom) CustomResolutionDialog(size,
onSave = { value -> editCustom = false; onPick("${value.first}x${value.second}") },
onDismiss = { editCustom = false })
}
@@ -0,0 +1,19 @@
package com.droiddeck.launcher.ui
/** Stable page identities keep focus and scroll positions when a secondary display comes and goes. */
internal enum class SessionDrawerPage(val firstControl: String) {
CONTROLLER("touch"),
SECOND_SCREEN("second-screen:none"),
DISPLAY("hud"),
EFFECTS("look"),
GAMES("anisotropy"),
SESSION("suspend"),
}
internal fun sessionDrawerPages(hasSecondScreen: Boolean): List<SessionDrawerPage> =
SessionDrawerPage.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN }
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
val index = indexOf(current).coerceAtLeast(0)
return this[Math.floorMod(index + direction, size)]
}
@@ -70,6 +70,9 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow
// The session's loading screen, its paused screen and the HUD text style.
@@ -273,7 +276,11 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
.padding(horizontal = 12.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
Text("B", color = colors.background, style = TextStyle(
fontSize = 12.sp, lineHeight = 12.sp, fontWeight = FontWeight.Bold,
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both),
))
}
Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
@@ -28,15 +28,24 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.outlined.AutoFixHigh
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.ScreenShare
import androidx.compose.material.icons.outlined.PictureInPictureAlt
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.draw.clipToBounds
import android.view.Display
import androidx.activity.compose.BackHandler
@@ -79,6 +88,8 @@ 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.ui.draw.rotate
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
@@ -93,7 +104,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -129,22 +139,33 @@ import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
import java.util.Locale
private val drawerPageTitles = listOf(R.string.drawer_page_display, R.string.drawer_page_effects, R.string.drawer_page_controls, R.string.drawer_page_components, R.string.drawer_page_settings)
private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller
SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen
SessionDrawerPage.DISPLAY -> R.string.drawer_page_display
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
SessionDrawerPage.SESSION -> R.string.mode_session
}
/** One icon per drawer page, in page order (QAM-style tabs). */
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.AutoFixHigh, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "look", "touch", "cmp-proton", "suspend")
private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports
SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare
SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
}
private class DrawerFocus {
private val requesters = HashMap<String, FocusRequester>()
private val last = arrayOfNulls<String>(DRAWER_PAGES)
private val last = mutableMapOf<SessionDrawerPage, String>()
var focused by mutableStateOf<String?>(null)
private set
private fun requester(key: String) = requesters.getOrPut(key) { FocusRequester() }
fun track(page: Int, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
fun track(page: SessionDrawerPage, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
if (it.isFocused) {
last[page] = key
focused = key
@@ -152,9 +173,9 @@ private class DrawerFocus {
}
/** Where focus was last on [page], if anywhere. */
fun target(page: Int) = last[page]
fun target(page: SessionDrawerPage) = last[page]
fun request(key: String) = runCatching { requester(key).requestFocus() }
fun forget(page: Int) { last[page] = null }
fun forget(page: SessionDrawerPage) { last.remove(page) }
}
/** Everything the drawer shows and does. */
@@ -168,13 +189,13 @@ class DrawerActions(
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
val onScreenButtonsVisible: Boolean,
val suspendPolicy: String,
val backActionsInverted: Boolean,
val touchMode: String,
val touchAuto: String,
val shapeMode: String,
val fexPreset: String,
/** Steam only: games stretched to the screen's size, changed live (null = not Steam). */
/** Steam only: forces game windows fullscreen, changed live (null = not Steam). */
val fillScreen: Boolean? = null,
val upscaler: Int = 0,
val upscaleSharpness: Int = 75,
@@ -204,7 +225,6 @@ class DrawerActions(
val onSuspendPolicy: (String) -> Unit,
val onBackActionsInverted: (Boolean) -> Unit,
val onTouch: (String) -> Unit,
val onShape: (String) -> Unit,
val onFexPreset: (String) -> Unit,
val onFillScreen: (Boolean) -> Unit = {},
val onUpscaler: (Int) -> Unit = {},
@@ -225,7 +245,7 @@ class DrawerActions(
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
/** Games tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
val onComponentsRefresh: () -> Unit = {},
@@ -235,16 +255,20 @@ class DrawerActions(
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageChange: (Int) -> Unit, a: DrawerActions) {
internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) {
val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty())
val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER
LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) }
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val host = rememberMenuHost()
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var advancedEffectsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
val pageScroll = remember { SessionDrawerPage.entries.associateWith { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
val focus = remember { DrawerFocus() }
val inputModeManager = LocalInputModeManager.current
@@ -254,13 +278,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
BackHandler(enabled = open && confirmStop) { confirmStop = false }
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() }
LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() }
LaunchedEffect(open, controllerActive) {
if (open && !controllerActive) focusManager.clearFocus(force = true)
}
// The page focus was last put on: turning to another (LB, RB, a tab) starts it at its first
// option; anything else (a menu closing, the drawer opening again) goes back where it was.
var focusedPage by remember { mutableIntStateOf(page) }
var focusedPage by remember { mutableStateOf(page) }
LaunchedEffect(open, page, controllerActive, host.open, appToChooseDisplay, confirmStop) {
if (!open) {
host.open = null
@@ -270,8 +294,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
if (host.open != null || appToChooseDisplay != null || confirmStop) return@LaunchedEffect
val turned = page != focusedPage
focusedPage = page
// The page's top control: on Settings that is Android apps, when it is shown.
val first = if (page == drawerPageEntries.lastIndex && a.isHomeApp) "apps" else drawerPageEntries[page]
// The page's top control: on Session that is Android apps, when it is shown.
val first = if (page == SessionDrawerPage.SESSION && a.isHomeApp) "apps" else page.firstControl
if (turned) focus.forget(page)
val target = focus.target(page) ?: first
repeat(24) {
@@ -350,31 +374,21 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.fillMaxWidth().padding(top = if (short) 6.dp else 12.dp),
) {
DrawerBumper("LB", stringResource(R.string.drawer_prev_page), modifier = focus.track(page, "prev")) {
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
host.open = null; onPageChange(pages.step(page, -1))
}
DrawerPageTabs(page = page, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerPageTabs(page = page, pages = pages, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerBumper("RB", stringResource(R.string.drawer_next_page), modifier = focus.track(page, "next")) {
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
host.open = null; onPageChange(pages.step(page, 1))
}
}
Column(
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll.getValue(page)),
) { CompositionLocalProvider(LocalChipMinWidth provides 120.dp) {
when (page) {
0 -> {
SessionDrawerPage.DISPLAY -> {
SettingsGroup(stringResource(R.string.drawer_page_display)) {
ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn,
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
if (a.fillScreen != null) ToggleRow(
host, "fill", stringResource(R.string.drawer_fill), null, a.fillScreen,
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
ChoiceRow(host, "upscaler", stringResource(R.string.drawer_scaling), null,
SessionPrefs.upscalerChoices, a.upscaler,
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
SliderRow(stringResource(R.string.drawer_scaling_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
enabled = a.upscaler != 0, format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"),
onChange = a.onUpscaleSharpness)
val fgOpen = host.open == "fg"
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
@@ -384,14 +398,27 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, a.upscaler,
note = stringResource(R.string.display_filter_note),
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
}
if (a.fillScreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
stringResource(R.string.display_force_fullscreen_live), a.fillScreen,
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen)
}
}
DRAWER_PAGE_EFFECTS -> {
ScreenEffectsGroup(host, a) { key -> focus.track(page, key) }
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
SessionDrawerPage.EFFECTS -> {
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
}
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
DRAWER_PAGE_CONTROLS -> {
SettingsGroup(stringResource(R.string.drawer_page_controls)) {
SessionDrawerPage.CONTROLLER -> {
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
@@ -402,12 +429,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
a.controller?.let { c ->
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
if (a.onScreenButtonsVisible) {
if (a.steam) {
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
}
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
}
}
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
@@ -423,18 +454,23 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup(stringResource(R.string.drawer_second_screen)) {
ChoiceRow(host, "second-screen-mode", stringResource(R.string.drawer_second_screen_shows), null,
(listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()))
.map { it to it.label },
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
chipModifier = focus.track(page, "second-screen-display"), onPick = a.onSecondScreenDisplay)
}
}
else -> {
SessionDrawerPage.SECOND_SCREEN -> {
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.GAMES -> {
Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null,
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false)
}
ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.SESSION -> {
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
DrawerOutlineButton(if (androidAppsExpanded) stringResource(R.string.common_hide) else stringResource(R.string.common_show), modifier = focus.track(page, "apps")) {
@@ -466,7 +502,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
SettingsGroup(stringResource(R.string.drawer_session_behavior)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.drawer_suspend_hint),
null,
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
@@ -478,17 +514,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
onPick = a.onSuspendPolicy,
)
}
SettingsGroup(stringResource(R.string.drawer_next_session)) {
ChoiceRow(host, "shape", stringResource(R.string.mode_ratio), null,
SessionPrefs.shapeChoices, a.shapeMode,
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
}
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
}
SettingsGroup(stringResource(R.string.drawer_support)) {
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs")) {
@@ -501,12 +526,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
a.pipAutoEnter, onChange = a.onPipAutoEnter)
}
Spacer(Modifier.height(18.dp))
if (a.pipSupported) DrawerOutlineButton(stringResource(R.string.pip_title), modifier = focus.track(page, "pip")) {
host.open = null; a.onPip()
}
if (!a.isHomeApp) DrawerOutlineButton(stringResource(R.string.drawer_background), modifier = focus.track(page, "background")) {
host.open = null; a.onBackground()
Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (a.pipSupported) DrawerActionButton(stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt,
modifier = focus.track(page, "pip")) { host.open = null; a.onPip() }
if (!a.isHomeApp) DrawerActionButton(stringResource(R.string.drawer_background), Icons.AutoMirrored.Outlined.ExitToApp,
modifier = focus.track(page, "background")) { host.open = null; a.onBackground() }
}
}
}
@@ -546,6 +570,27 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
@Composable
private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
.glideBorder(hot, shape, pal.signal)
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(22.dp))
Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
@Composable
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
@@ -636,58 +681,60 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier
/**
* The drawer's page tabs, QAM-style: one icon per page standing in a line in page order. The
* selected one steps forward - full size, bright, a soft glow - while the others step back to
* half size and fade, and the whole line leans a little toward the selection. LB / RB still turn
* a little smaller and fade, and the whole line leans a little toward the selection. LB / RB still turn
* the page (the buttons beside it and the pad's bumpers); a tab can be tapped or picked with the
* pad like the dots it replaces.
*/
@Composable
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (Int) -> Unit) {
private fun DrawerPageTabs(page: SessionDrawerPage, pages: List<SessionDrawerPage>, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (SessionDrawerPage) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val count = drawerPageIcons.size
val count = pages.size
val selectedIndex = pages.indexOf(page)
val middle = (count - 1) / 2f
val motion = Motion.tw<Float>(340, easing = FastOutSlowInEasing)
androidx.compose.foundation.layout.BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(),
) {
val tab = if (compact) 40f else 56f
val tab = if (compact) 36f else 44f
// Fit the line to the room between LB and RB. Worst case is an end tab selected: full size
// ([tab] dp) and pushed outward by the lean, which scales with the spacing. So
// (count-1) * spacing * (1 + lean/64) + 56 <= width
// - never more than the mock's 64 dp, never tighter than 32 dp.
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP)
// (count-1) * spacing * (1 + lean/spacingMax) + tab <= width
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(24f, DRAWER_TAB_SPACING_DP)
val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP
val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean")
drawerPageIcons.forEachIndexed { index, icon ->
val selected = index == page
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(drawerPageTitles[index]))
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
val select = { onSelect(index) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * spacing + lean).dp)
.size(tab.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
.background(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = pageDescription }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
val lean by animateFloatAsState(-(selectedIndex - middle) * leanStep, motion, label = "tabLean")
pages.forEachIndexed { index, item ->
androidx.compose.runtime.key(item) {
val selected = item == page
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(item.title))
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
val select = { onSelect(item) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * spacing + lean).dp)
.size(tab.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
.background(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = pageDescription }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Icon(item.icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
}
}
}
}
@@ -696,54 +743,114 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
/** The Look row's value when the rows below match no Look. */
private const val LOOK_CUSTOM = "custom"
/**
* Screen effects on the Effects page: a Look row that moves every row under it, then the rows
* themselves: switches and sliders the d-pad steps (SliderRow), so a pad drives them.
*/
/** Presets and simple effects first; detailed adjustments stay behind Advanced. */
@Composable
private fun ScreenEffectsGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
private fun ScreenEffectsGroup(
host: MenuHost, a: DrawerActions, advancedExpanded: Boolean, onAdvancedToggle: () -> Unit,
track: (String) -> Modifier,
) {
val e = a.effects
val look = ScreenEffectLooks.match(e, a.upscaler)
val preset = ScreenEffectLooks.match(e, a.upscaler)
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
SettingsGroup(stringResource(R.string.drawer_effects)) {
val looks = ScreenEffectLooks.LOOKS.map { it.name to it.name }
ChoiceRow(host, "look", stringResource(R.string.drawer_look),
look?.desc ?: stringResource(R.string.drawer_effects_hint),
if (look == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + looks else looks,
look?.name ?: LOOK_CUSTOM, chipModifier = track("look"), hintLines = 2) { name ->
val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name }
ChoiceRow(host, "look", stringResource(R.string.drawer_look), null,
if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets,
preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name ->
ScreenEffectLooks.LOOKS.firstOrNull { it.name == name }?.let { picked ->
picked.scalingMode?.let(a.onUpscaler)
a.onEffects(picked.effects)
}
}
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5, enabled = e.cas,
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5, enabled = e.deband,
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
ToggleRow(host, "fxaa", stringResource(R.string.drawer_fxaa), null, e.fxaa, chipModifier = track("fxaa")) { a.onEffects(e.copy(fxaa = it)) }
ToggleRow(host, "toon", stringResource(R.string.drawer_toon), null, e.toon, chipModifier = track("toon")) { a.onEffects(e.copy(toon = it)) }
ToggleRow(host, "crt", stringResource(R.string.drawer_crt), null, e.crt, chipModifier = track("crt")) { a.onEffects(e.copy(crt = it)) }
ToggleRow(host, "ntsc", stringResource(R.string.drawer_ntsc), null, e.ntsc, chipModifier = track("ntsc")) { a.onEffects(e.copy(ntsc = it)) }
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
}
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val rotation by animateFloatAsState(if (advancedExpanded) 180f else 0f, Motion.tw(180), label = "advancedArrow")
val description = stringResource(if (advancedExpanded) R.string.widgets_expanded else R.string.widgets_collapsed)
val toggle = { host.open = null; onAdvancedToggle() }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = track("effects-advanced").fillMaxWidth().padding(top = 12.dp).heightIn(min = 48.dp)
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle).semantics { stateDescription = description }.padding(horizontal = 4.dp),
) {
Text(stringResource(R.string.drawer_effects_advanced).uppercase(), fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
Icon(Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.onSurfaceVariant,
modifier = Modifier.size(22.dp).rotate(rotation))
}
AnimatedVisibility(advancedExpanded) {
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(colors.surface).border(1.dp, pal.line, RoundedCornerShape(14.dp))) {
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
if (e.cas) SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5,
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
if (e.deband) SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5,
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
}
}
}
/** Texture filtering on the Effects page: DXVK options for the next DirectX 9-11 launch. */
/** Secondary-display modes are mutually exclusive and apply immediately. */
@Composable
private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()
SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) {
Column(Modifier.fillMaxWidth().selectableGroup()) {
modes.forEach { mode ->
val selected = a.secondScreenMode == mode
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val pick = { host.open = null; a.onSecondScreenMode(mode) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = track("second-screen:${mode.id}").fillMaxWidth().heightIn(min = 56.dp)
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(source)
.selectable(selected, interactionSource = source, indication = null, role = Role.RadioButton, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
RadioButton(selected, onClick = null,
colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant))
}
}
}
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
chipModifier = track("second-screen-display"), onPick = a.onSecondScreenDisplay)
}
}
/** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */
@Composable
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
SettingsGroup(stringResource(R.string.drawer_texture)) {
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), stringResource(R.string.drawer_texture_hint),
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), stringResource(R.string.drawer_texture_hint),
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
}
@@ -751,16 +858,8 @@ private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (Stri
private fun signed(v: Int) = if (v > 0) "+$v" else "$v"
/** Display, Effects, Controls, Components, Settings. */
const val DRAWER_PAGES = 5
private const val DRAWER_PAGE_EFFECTS = 1
private const val DRAWER_PAGE_CONTROLS = 2
private const val DRAWER_PAGE_COMPONENTS = 3
/**
* The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap
* The Games tab's components: quick swaps between what is already installed, per Proton. A swap
* into the Proton a running game uses waits until that game closes; downloading, importing and
* deleting stay on the Components page in the app.
*/
@@ -884,14 +983,14 @@ private fun <T> DrawerStackedChoice(
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
private const val DRAWER_TAB_SPACING_DP = 64f
/** A close row of icons: inactive tabs stay legible while the active tab leads. */
private const val DRAWER_TAB_SPACING_DP = 48f
private const val DRAWER_TAB_LEAN_DP = 12f
private const val DRAWER_TAB_LEAN_DP = 8f
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
private const val DRAWER_TAB_SIDE_SCALE = 0.8f
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
private const val DRAWER_TAB_SIDE_ALPHA = 0.8f
/** Room between the Stop pill and the box that steps out under it: the pull. */
private val StepPull = 6.dp
@@ -29,6 +29,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -60,6 +61,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -82,6 +84,10 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.setProgress
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.style.TextAlign
import kotlin.math.roundToInt
import androidx.compose.ui.input.InputMode
@@ -468,8 +474,16 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
fun SliderRow(
label: String, hint: String?, value: Int, range: IntRange, step: Int = 1, enabled: Boolean = true,
format: (Int) -> String = { it.toString() }, modifier: Modifier = Modifier, onChange: (Int) -> Unit,
) = SettingsRow(label, hint) {
ValueSlider(value, range, step, enabled, label, format, modifier, onChange)
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth()) {
ValueSlider(value, range, step, enabled, label, format,
modifier = modifier.padding(horizontal = 6.dp, vertical = 3.dp), onChange = onChange)
if (hint != null) Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 8.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
@Composable
@@ -479,34 +493,39 @@ fun ValueSlider(
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val currentOnChange by rememberUpdatedState(onChange)
val span = (range.last - range.first).coerceAtLeast(1)
val fraction = ((value - range.first).toFloat() / span).coerceIn(0f, 1f)
val shape = RoundedCornerShape(99.dp)
val thumbInset = with(LocalDensity.current) { 8.dp.toPx() }
var trackPx by remember { mutableIntStateOf(0) }
val text = format(value)
// A step from the d-pad lands on the grid of [step] from the range's start; a Look's own
// value, which need not be on it, is kept until the user moves.
val shape = RoundedCornerShape(8.dp)
// Preserve off-grid preset values until the user adjusts; every input snaps to the same step.
fun stepped(v: Int) = (((v - range.first).toFloat() / step).roundToInt() * step + range.first).coerceIn(range.first, range.last)
fun fromX(x: Float) { if (trackPx > 0) onChange(stepped(range.first + (x / trackPx * span).roundToInt())) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.heightIn(min = 48.dp)
fun fromX(x: Float) {
val width = trackPx - 2 * thumbInset
if (width > 0) {
val position = ((x - thumbInset) / width).coerceIn(0f, 1f)
currentOnChange(stepped(range.first + (position * span).roundToInt()))
}
}
Column(
modifier = modifier.fillMaxWidth().heightIn(min = 72.dp)
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.glideBorder(hot, shape, pal.signal)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src)
.focusable(enabled, src)
.hoverable(source).focusable(enabled, source)
.onPreviewKeyEvent { event ->
val keyEvent = event.nativeKeyEvent
val left = keyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) {
false
} else {
// Every down counts, repeats included, so a held direction keeps stepping.
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) false
else {
// Repeated key-down events let a held direction keep adjusting.
if (enabled && keyEvent.action == KeyEvent.ACTION_DOWN) {
val next = stepped(value + if (left) -step else step)
if (next != value) onChange(next)
if (next != value) currentOnChange(next)
}
true
}
@@ -515,28 +534,33 @@ fun ValueSlider(
stateDescription = text
progressBarRangeInfo = ProgressBarRangeInfo(value.toFloat(), range.first.toFloat()..range.last.toFloat())
if (label != null) contentDescription = label
},
if (!enabled) disabled()
setProgress { next ->
if (!enabled) false else { currentOnChange(stepped(next.roundToInt())); true }
}
}
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, textAlign = TextAlign.End,
maxLines = 1, modifier = Modifier.width(56.dp))
Spacer(Modifier.width(10.dp))
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.size(width = 164.dp, height = 30.dp).clip(shape).background(colors.surfaceVariant)
.glideBorder(hot, shape, colors.onBackground, pal.line2)
.onSizeChanged { trackPx = it.width }
.pointerInput(enabled, range, step) { if (enabled) detectTapGestures { fromX(it.x) } }
.pointerInput(enabled, range, step) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (label != null) Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
color = colors.onBackground, modifier = Modifier.weight(1f))
else Spacer(Modifier.weight(1f))
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1)
}
Canvas(
Modifier.fillMaxWidth().height(40.dp).onSizeChanged { trackPx = it.width }
.pointerInput(enabled, range, step, thumbInset) { if (enabled) detectTapGestures { fromX(it.x) } }
.pointerInput(enabled, range, step, thumbInset) {
if (enabled) detectHorizontalDragGestures { change, _ -> fromX(change.position.x); change.consume() }
},
) {
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(pal.signal))
Box(
Modifier.padding(4.dp).size(22.dp)
.graphicsLayer { translationX = fraction * (trackPx - 30.dp.toPx()).coerceAtLeast(0f) }
.clip(CircleShape)
.background(pal.onSignal),
)
val start = Offset(thumbInset, size.height / 2f)
val end = Offset(size.width - thumbInset, start.y)
val thumb = Offset(start.x + (end.x - start.x) * fraction, start.y)
val fill = if (hot) pal.signal else colors.onBackground
drawLine(colors.onSurfaceVariant.copy(alpha = 0.35f), start, end, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
if (fraction > 0) drawLine(fill, start, thumb, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
drawCircle(fill, radius = 7.dp.toPx(), center = thumb)
}
}
}
@@ -584,6 +608,9 @@ fun SettingsPage(
scroll: androidx.compose.foundation.ScrollState? = null,
scrollContent: Boolean = true,
compactLayout: Boolean = false,
/** Controls that remain below the title while the settings scroll, such as a tab strip. */
header: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
val scrollState = scroll ?: rememberScrollState()
@@ -591,7 +618,7 @@ fun SettingsPage(
val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim")
val narrow = LocalNarrowPane.current && !compactLayout
Column(
modifier = Modifier.fillMaxSize().padding(
modifier = modifier.fillMaxSize().padding(
horizontal = if (compactLayout) 14.dp else if (narrow) 16.dp else 22.dp,
vertical = if (compactLayout) 6.dp else if (narrow) 12.dp else 18.dp,
),
@@ -656,6 +683,7 @@ fun SettingsPage(
}
}
}
if (header != null) header()
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
+27 -1
View File
@@ -474,6 +474,7 @@
<string name="drawer_page_effects">Effects</string>
<string name="drawer_page_controls">Controls</string>
<string name="drawer_page_components">Components</string>
<string name="drawer_page_controller">Controller</string>
<string name="drawer_page_settings">Settings</string>
<string name="drawer_page_desc">%1$s page</string>
<string name="drawer_steam">Steam</string>
@@ -489,8 +490,11 @@
<string name="drawer_scaling_sharpness">Scaling sharpness</string>
<string name="drawer_effects">Screen effects</string>
<string name="drawer_effects_hint">Live. Costs a little GPU time while any effect is on.</string>
<string name="drawer_look">Look</string>
<string name="drawer_look">Presets</string>
<string name="drawer_look_custom">Custom</string>
<string name="drawer_effects_advanced">Advanced</string>
<string name="widgets_expanded">Expanded</string>
<string name="widgets_collapsed">Collapsed</string>
<string name="drawer_cas">Sharpening (CAS)</string>
<string name="drawer_cas_level">Sharpening level</string>
<string name="drawer_fake_hdr">Fake HDR</string>
@@ -514,6 +518,7 @@
<string name="drawer_pc_keyboard">PC keyboard</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Second screen</string>
<string name="drawer_second_screen_mode">Mode</string>
<string name="drawer_second_screen_shows">Shows</string>
<string name="drawer_android_apps">Android apps</string>
<string name="drawer_launch_app">Launch an app</string>
@@ -1015,4 +1020,25 @@
<string name="game_file_folder">Choose a folder for frontend game files</string>
<string name="game_file_exported">Game files saved to %1$s</string>
<string name="game_frontend_files">Frontend files</string>
<string name="display_session">Session display</string>
<string name="display_resolution">Resolution</string>
<string name="display_game_fit">16:9 game fit</string>
<string name="display_game_fit_hint">How 16:9 games fit this display. Next session.</string>
<string name="display_game_fit_unavailable">Choose Match screen or a custom size narrower than 16:9.</string>
<string name="display_preserve">Keep proportions</string>
<string name="display_stretch">Stretch to display</string>
<string name="display_image_scaling">Image scaling</string>
<string name="display_filter">Scaling filter</string>
<string name="display_filter_hint">Shared by Steam and Desktop.</string>
<string name="display_filter_live">Applies live. Shared by Steam and Desktop.</string>
<string name="display_filter_note">FSR, GSR and NIS apply when enlarging the session image. Sharpen only also works without enlargement.</string>
<string name="display_sharpness">Filter sharpness</string>
<string name="display_frame_rate">Frame rate</string>
<string name="display_window_compatibility">Game window compatibility</string>
<string name="display_force_fullscreen">Force game windows fullscreen</string>
<string name="display_force_fullscreen_hint">Keeps games fullscreen after focus changes. Turn off if a game draws small. Applies live during a session.</string>
<string name="display_force_fullscreen_live">Keeps games fullscreen after focus changes. Applies live.</string>
<string name="display_look_filter">%1$s Sets scaling to %2$s.</string>
<string name="display_match_screen">Match screen (%1$d×%2$d)</string>
<string name="mode_tab_steam">Steam</string>
</resources>
@@ -0,0 +1,58 @@
package com.droiddeck.launcher.session
import org.junit.Assert.*
import org.junit.Test
class SessionDisplayTest {
@Test fun explicitPresetsAndMatchScreenResolveWithoutAnyAspectSetting() {
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "1280x720"))
assertEquals(1600 to 900, SessionDisplay.resolveChoice(1280 to 720, "1600x900"))
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1080 to 1440, SessionDisplay.MATCH_SCREEN))
assertEquals(2400 to 1080, SessionDisplay.resolveChoice(1080 to 2400, SessionDisplay.MATCH_SCREEN))
}
@Test fun panelOptionReplacesTheIdenticalPresetAndTracksPanelChanges() {
val thor = SessionDisplay.resolutionOptions(1920 to 1080)
assertEquals(listOf("1280x720", "1600x900", SessionDisplay.MATCH_SCREEN), thor)
val small = SessionDisplay.resolutionOptions(1280 to 720)
assertFalse(small.contains("1280x720"))
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN))
assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN))
}
@Test fun resolutionCapsPreserveTheSelectedShapeWithoutExceedingPanelHeight() {
assertEquals(1280 to 720, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_AUTO))
assertEquals(960 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_EXACT))
assertEquals(1280 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_AUTO))
assertEquals(1280 to 720, SessionDisplay.resolve(1280 to 720, 1080, SessionPrefs.SHAPE_AUTO))
}
@Test fun noCapUsesPanelHeightWhileCustomOverridesBothCapAndAspect() {
assertEquals(1440 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_EXACT))
assertEquals(1920 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE))
assertEquals(1024 to 768, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_WIDE, 1024 to 768))
}
@Test fun orientationAndOddDimensionsKeepTheExistingEvenLandscapeDisplay() {
assertEquals(1920 to 1080, SessionDisplay.resolve(1081 to 1921, 0, SessionPrefs.SHAPE_EXACT))
assertEquals(1600 to 720, SessionDisplay.resolve(2400 to 1080, 720, SessionPrefs.SHAPE_AUTO))
}
@Test fun gameStretchAvailabilityMatchesTheRuntimeIncludingRounding() {
assertTrue(SessionDisplay.canStretch16x9(960 to 720))
assertFalse(SessionDisplay.canStretch16x9(1280 to 720))
assertFalse(SessionDisplay.canStretch16x9(1600 to 720))
val wide = SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE)
assertFalse(SessionDisplay.canStretch16x9(wide))
}
@Test fun removedFilterAliasesRetainTheirEquivalentBehavior() {
assertEquals(0, SessionPrefs.canonicalUpscaler(1))
assertEquals(4, SessionPrefs.canonicalUpscaler(5))
assertEquals(0, SessionPrefs.canonicalUpscaler(99))
assertFalse(SessionPrefs.upscalerHasSharpness(0))
assertFalse(SessionPrefs.upscalerHasSharpness(1))
assertFalse(SessionPrefs.upscalerHasSharpness(2))
for (mode in 3..8) assertTrue(SessionPrefs.upscalerHasSharpness(mode))
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.session
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SessionScalingPrefsTest {
@Before fun clearSettings() {
RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun explicitLegacyAspectAndCapKeepTheirSizeUntilAResolutionIsPicked() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putString("shape", SessionPrefs.SHAPE_EXACT).putInt("resolutionCap.steam", 900).commit()
assertEquals("1200x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", "1600x900")
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).commit()
assertEquals("1600x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
assertTrue(SessionPrefs.resolutionChosen(context, "steam"))
}
@Test fun matchScreenOverridesLegacyCustomAndAspectWithoutAffectingTheOtherMode() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).putString("customRes.steam", "1024x768")
.putString("customRes.lxqt", "800x600").commit()
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", SessionDisplay.MATCH_SCREEN)
val choice = SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080)
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1440 to 1080, choice))
assertEquals("800x600", SessionPrefs.resolutionChoice(context, "lxqt", 1440 to 1080))
}
@Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() {
val context = RuntimeEnvironment.getApplication()
assertEquals("1280x720", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", "1025×769")
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
}
@Test fun legacySavedFiltersStillResolveToAVisibleEquivalentChoice() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putInt("upscaler", 1).putInt("upscaleSharpness", 65).commit()
assertEquals(0, SessionPrefs.upscaler(context))
prefs.edit().putInt("upscaler", 5).commit()
assertEquals(4, SessionPrefs.upscaler(context))
assertEquals(65, SessionPrefs.upscaleSharpness(context))
SessionPrefs.setUpscaler(context, 5)
assertEquals(4, prefs.getInt("upscaler", -1))
SessionPrefs.setUpscaler(context, 1)
assertEquals(0, prefs.getInt("upscaler", -1))
}
}
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.*
import org.junit.Test
class SessionDrawerPageTest {
@Test fun singleScreenNavigationStartsWithControllerAndSkipsSecondScreen() {
val pages = sessionDrawerPages(false)
assertEquals(SessionDrawerPage.CONTROLLER, pages.first())
assertFalse(SessionDrawerPage.SECOND_SCREEN in pages)
assertEquals(SessionDrawerPage.DISPLAY, pages.step(SessionDrawerPage.CONTROLLER, 1))
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.CONTROLLER, -1))
assertEquals(SessionDrawerPage.CONTROLLER, pages.step(SessionDrawerPage.SESSION, 1))
}
@Test fun connectedSecondaryDisplayAddsItsPageAfterControllerAndPreservesOtherPages() {
val connected = sessionDrawerPages(true)
assertEquals(SessionDrawerPage.SECOND_SCREEN, connected.step(SessionDrawerPage.CONTROLLER, 1))
assertEquals(SessionDrawerPage.DISPLAY, connected.step(SessionDrawerPage.SECOND_SCREEN, 1))
assertEquals(sessionDrawerPages(false), connected.filter { it != SessionDrawerPage.SECOND_SCREEN })
for (pages in listOf(connected, sessionDrawerPages(false))) {
assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.DISPLAY, 1))
assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.EFFECTS, 1))
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
}
}
}