feat(ui): lift the Play sheet out from under Play

This commit is contained in:
xXJsonDeruloXx committed 2026-10-10 18:35:08 -04:00
1 parent 4c2ed55e9f
commit 68fbcd6f39
11 files changed
+1199 -342

No files matched your search

@@ -55,7 +55,6 @@ import com.droiddeck.launcher.core.WifiDiscovery
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage
import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.ConfirmDialog
@@ -339,7 +338,7 @@ class MainActivity : ComponentActivity() {
romsDir = path
}
}
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
// The mode whose lifted sheet last read its settings, with what it shows; refreshed by loadModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
@@ -549,9 +548,7 @@ class MainActivity : ComponentActivity() {
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showComponents -> { { ComponentsHost() } }
@@ -572,7 +569,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
pageKey = if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
appScale = appScale,
@@ -675,9 +672,10 @@ class MainActivity : ComponentActivity() {
},
// The activity re-attaches to the session that is running; nothing restarts.
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
onSteamSettings = { loadModeSettings(SessionService.MODE_STEAM) },
onRemoveDesktop = { removeDesktop() },
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
onDesktopSettings = { loadModeSettings(SessionService.MODE_DESKTOP) },
modeSheet = { mode, tab, extras -> ModeSheetHost(mode, tab, extras) },
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
onRuntime = { onRuntimeButton() },
@@ -729,7 +727,7 @@ class MainActivity : ComponentActivity() {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onPageBack = { showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
@@ -839,7 +837,7 @@ class MainActivity : ComponentActivity() {
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onMapping = { showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
@@ -1120,7 +1118,6 @@ class MainActivity : ComponentActivity() {
}
private fun openProtons() {
settingsMode = null
showPerformance = false
showComponents = false
showMapping = false
@@ -1146,7 +1143,6 @@ class MainActivity : ComponentActivity() {
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
focusComponentsContent = focusContent
if (tab != null) components.compComp = tab
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
@@ -1267,8 +1263,10 @@ class MainActivity : ComponentActivity() {
}
@Composable
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
private fun ModeSheetHost(mode: String, tab: String, extras: com.droiddeck.launcher.ui.SheetExtras) {
// Read for the other mode last (or not yet): what this one shows is read as it opens.
if (settingsMode != mode) return
com.droiddeck.launcher.ui.ModeSheetRows(
ModeSettings(
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
@@ -1396,8 +1394,9 @@ class MainActivity : ComponentActivity() {
onPickDeckyPluginZip = {
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
},
onDismiss = { settingsMode = null },
onDismiss = {},
),
tab, extras,
)
}
@@ -1496,13 +1495,9 @@ class MainActivity : ComponentActivity() {
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private fun openModeSettings(mode: String) {
private fun loadModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
@@ -1532,8 +1527,8 @@ class MainActivity : ComponentActivity() {
gameStorage = SessionPrefs.gameStorage(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
settingsMode = mode
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it animates in.
// The sheet lifts at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it rises.
Thread({
drivers.refreshDrivers()
val storage = GameStorage.options(this)
@@ -180,8 +180,12 @@ class FrontEndActions(
val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {},
val onRom: (Library.Rom) -> Unit,
val onResume: () -> Unit,
/** Reads Steam's session settings for its lifted sheet (under Play). */
val onSteamSettings: () -> Unit,
/** Reads the desktop's session settings for its lifted sheet (under Open desktop). */
val onDesktopSettings: () -> Unit,
/** A lifted sheet's rows for one tab of [mode]'s settings, as last read. */
val modeSheet: @Composable (mode: String, tab: String, extras: SheetExtras) -> Unit = { _, _, _ -> },
val onRemoveDesktop: () -> Unit = {},
val onInstallPackage: (String) -> Unit,
val onRemovePackage: (String) -> Unit,
@@ -455,9 +459,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
val railSelection = when {
// A hop's page belongs to the rail item it flew to.
Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
s.pageKey?.startsWith("settings:steam") == true -> "steam"
s.pageKey?.startsWith("settings:") == true -> "desktop"
// Performance opens from Play's sheet, and is Steam's.
s.pageKey == "performance" -> "steam"
s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
selected.startsWith("app:") -> "games"
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
else -> s.pageKey ?: selected
@@ -470,7 +474,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
if (key == "components") a.onComponents(focusContent)
else {
if (s.pageKey != null) a.onPageBack()
// Another section: no way back to keep, and no sheet to put back.
Hops.origin = null
Hops.restore = null
selected = key
}
}
@@ -563,6 +569,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
// Dots on their way somewhere: a hop to another section and back, a swap flying home.
FlightsLayer(screenAt)
// A confirm stepping out of the control that asked for it.
StepsLayer(screenAt)
appToChooseDisplay?.let { app ->
val secondaryDisplay = s.secondScreenDisplays.firstOrNull()
@@ -0,0 +1,645 @@
package com.droiddeck.launcher.ui
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import com.droiddeck.launcher.R
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// Play's settings, lifted out from under Play instead of opened on a page of their own. At rest a
// lip tucks out under Play and says what it will do. Down (or a pull up, or a tap on the lip)
// stretches the lip, the wall sinks back, and a sheet rises from the pane's foot with Play riding
// its top edge as the handle - StepOut's move, with the pane for a box. B, Up from the chips or a
// tap above folds it away; A on Play launches straight from it, the sheet folding into the pill
// before the launch flood. Open desktop does the same with the desktop's settings.
/** The sheets' tabs (their chips), by id. */
internal object SheetTab {
const val DISPLAY = "display"
const val CONTROLS = "controls"
const val AUDIO = "audio"
const val SESSION = "session"
const val LIBRARY = "library"
const val STEAM = "steam"
const val FILES = "files"
}
/**
* What the lip says, in order, by part: the session's size, then its frame-rate cap, its upscaler
* and its touch mode, each left out at its default (null or 0) to keep the lip short.
*/
internal fun lipParts(size: Pair<Int, Int>, fps: Int, fpsLabel: (Int) -> String, upscaler: String?, touch: String?): List<Pair<String, String>> = buildList {
add("size" to "${size.first}×${size.second}")
if (fps > 0) add("fps" to fpsLabel(fps))
if (upscaler != null) add("upscaler" to upscaler)
if (touch != null) add("touch" to touch)
}
/** The parts of [now] whose value differs from what the lip showed last ([seen]); none the first time. */
internal fun lipChanged(seen: Map<String, String>?, now: List<Pair<String, String>>): Set<String> =
if (seen == null) emptySet() else now.filter { (k, v) -> seen[k] != v }.map { it.first }.toSet()
/**
* Where the open sheet's top edge sits in a pane [paneH] tall (px), for a button [buttonH] tall
* riding it: 82dp down a 360dp pane, the same share of any other, and never so high that the
* button's top comes closer than [minAbove] to the pane's. [ride] is how far below the edge the
* button's centre sits.
*/
internal fun sheetTop(paneH: Float, buttonH: Float, minAbove: Float, ride: Float): Float =
maxOf(paneH * 82f / 360f, minAbove + buttonH / 2f - ride)
/** What the lip showed last, per sheet: a value that changed since is lit the next time it shows. */
private object LipMemory {
val seen = HashMap<String, Map<String, String>>()
}
/** One piece of the sheet's rows fades and rises in on its own beat (a settings group asks for its index). */
internal class SheetBeat(private val pieces: List<Animatable<Float, AnimationVector1D>>) {
private var next = 1
fun take(): Int = next++
fun value(i: Int): Float = pieces[i.coerceIn(0, pieces.lastIndex)].value
}
/** The sheet whose rows are being laid out, so each settings group can take its beat. */
internal val LocalSheetBeat = staticCompositionLocalOf<SheetBeat?> { null }
/** A settings group's piece of the sheet's entrance; nothing outside a sheet. */
@Composable
internal fun Modifier.sheetPiece(): Modifier {
val beat = LocalSheetBeat.current ?: return this
val i = remember { beat.take() }
return graphicsLayer {
val v = beat.value(i)
alpha = v
translationY = (1f - v) * 6.dp.toPx()
}
}
private val SheetInset = 12.dp
private val SheetCorner = 18.dp
private val ChipsTop = 14.dp
private val RowsTop = 54.dp
private const val PIECES = 12
/**
* A hero with one big button and the sheet that lifts out from under it. [key] names the sheet
* (a hop comes back to it, the lip remembers what it said); [tabs] are its chips (id to label) and
* [rows] what each holds. [background] sinks and [words] fade as it opens. [button] draws the
* button itself with the click it should make; [onPrimary] is what that click does once the
* sheet is out of the way. [onOpen] reads the settings the rows show.
*/
@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class)
@Composable
internal fun LiftHero(
key: String,
tabs: List<Pair<String, String>>,
lip: () -> List<Pair<String, String>>,
onOpen: () -> Unit,
onPrimary: () -> Unit,
background: @Composable () -> Unit,
words: @Composable () -> Unit,
button: @Composable (onClick: () -> Unit) -> Unit,
rows: @Composable (tab: String) -> Unit,
modifier: Modifier = Modifier,
start: Dp = 44.dp,
bottom: Dp = 44.dp,
) {
val density = LocalDensity.current
val inputMode = LocalInputModeManager.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val ff = LocalFrontFocus.current
val narrow = LocalNarrowPane.current
var open by remember { mutableStateOf(false) }
var shown by remember { mutableStateOf(false) }
var launching by remember { mutableStateOf(false) }
var tab by remember { mutableStateOf(0) }
var travel by remember { mutableStateOf(1) }
// The control to focus once a sheet put back by a hop's B has lifted, instead of the first chip.
var focusOnLift by remember { mutableStateOf<String?>(null) }
val pull = remember { Animatable(0f) }
val sink = remember { Animatable(0f) }
val lift = remember { Animatable(0f) }
val ride = remember { Animatable(0f) }
val hot = remember { Animatable(1f) }
val lipShow = remember { Animatable(0f) }
val lipGone = remember { Animatable(0f) }
val lipLit = remember { Animatable(0f) }
val fold = remember { Animatable(0f) }
val foldTop = remember { Animatable(0f) }
val pieces = remember { List(PIECES) { Animatable(0f) } }
val firstChip = remember { FocusRequester() }
val playSrc = remember { MutableInteractionSource() }
val playHovered by playSrc.collectIsHoveredAsState()
var playFocused by remember { mutableStateOf(false) }
var heroAt by remember { mutableStateOf(Offset.Zero) }
var rest by remember { mutableStateOf<Rect?>(null) }
var lit by remember { mutableStateOf(emptySet<String>()) }
fun focusPlay() { if (ff != null && inputMode.inputMode == InputMode.Keyboard) runCatching { ff.primary.requestFocus() } }
fun close() {
if (!open) return
// Focus leaves the rows before they go.
focusPlay()
open = false
}
// Back from a hop the sheet started (or a page it opened): put it back as it was.
LaunchedEffect(Unit) {
val r = Hops.restore ?: return@LaunchedEffect
if (r.surface != HOP_SURFACE_SHEET || r.rail != key) return@LaunchedEffect
Hops.restore = null
tab = tabs.indexOfFirst { it.first == r.tab }.coerceAtLeast(0)
focusOnLift = r.control
open = true
}
// Launched: the session covers the screen, and the sheet is put away under it.
val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = androidx.lifecycle.LifecycleEventObserver { _, e ->
if (e == androidx.lifecycle.Lifecycle.Event.ON_STOP && (open || shown)) {
open = false; shown = false; launching = false
}
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
LaunchedEffect(open, launching) {
if (open && !launching) {
shown = true
onOpen()
fold.snapTo(0f); foldTop.snapTo(0f)
coroutineScope {
// Pull: the lip stretches down as the wall sinks back and dims.
launch { pull.animateTo(24f, Motion.sp(0.7f, 380f)) }
launch { sink.animateTo(1f, Motion.tw(260)) }
// Lift: the sheet rises with Play riding its top edge; the lip lets go.
launch {
delay(Motion.ms(120).toLong())
launch { lipGone.animateTo(1f, Motion.tw(140)) }
launch { pull.animateTo(0f, Motion.sp(1f, 500f)) }
launch { lift.animateTo(1f, Motion.sp(0.7f, 380f)) }
launch { ride.animateTo(1f, Motion.sp(0.7f, 380f)) }
launch { hot.snapTo(1f); delay(Motion.ms(100).toLong()); hot.animateTo(0f, Motion.tw(200)) }
}
pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } }
if (inputMode.inputMode == InputMode.Keyboard) launch {
val control = focusOnLift
focusOnLift = null
delay(Motion.ms(if (control != null) 420 else 300).toLong())
withFrameNanos { }
val target = control?.let { ff?.items?.get(it) } ?: firstChip
runCatching { target.requestFocus() }
}
}
} else if (launching) {
// Play from the sheet: the rows go, the sheet folds into the pill, then the launch.
coroutineScope {
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); fold.animateTo(1f, Motion.sp(1f, 420f)) }
launch { delay(Motion.ms(160).toLong()); foldTop.animateTo(1f, Motion.sp(1f, 380f)) }
}
shown = false
onPrimary()
// The session covers the screen and the sheet is put away under it (ON_STOP). If
// nothing started, put it away anyway.
delay(3_000)
launching = false
} else if (shown) {
coroutineScope {
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); lift.animateTo(0f, Motion.sp(1f, 380f)) }
launch { delay(Motion.ms(100).toLong()); ride.animateTo(0f, Motion.sp(0.55f, 300f)) }
launch { sink.animateTo(0f, Motion.tw(300)) }
}
shown = false
lipGone.snapTo(0f)
hot.snapTo(1f)
} else {
// Snapped shut under a session: everything back at rest, no animation.
listOf(pull, sink, lift, ride, lipGone, fold, foldTop).forEach { it.snapTo(0f) }
pieces.forEach { it.snapTo(0f) }
hot.snapTo(1f)
}
}
BackHandler(enabled = open && !launching) { close() }
// The lip: there while Play has a pad or a pointer on it, or always with touch alone.
val touchOnly = inputMode.inputMode == InputMode.Touch
val lipWanted = !open && !shown && (playFocused || playHovered || touchOnly)
val lipNow = rememberUpdatedState(lip)
LaunchedEffect(lipWanted) {
if (lipWanted) {
delay(Motion.ms(160).toLong())
val now = lipNow.value()
lit = lipChanged(LipMemory.seen[key], now)
LipMemory.seen[key] = now.toMap()
coroutineScope {
launch { lipShow.animateTo(1f, Motion.tw(220)) }
if (lit.isNotEmpty()) launch { lipLit.snapTo(1f); lipLit.animateTo(0f, Motion.tw(600)) }
}
} else {
delay(Motion.ms(140).toLong())
lipShow.animateTo(0f, Motion.tw(220))
}
}
BoxWithConstraints(modifier.fillMaxSize().onGloballyPositioned { heroAt = it.boundsInRoot().topLeft }) {
val w = constraints.maxWidth.toFloat()
val h = constraints.maxHeight.toFloat()
val inset = with(density) { SheetInset.toPx() }
val restRect = rest?.translate(-heroAt)
val buttonH = restRect?.height ?: with(density) { 56.dp.toPx() }
val openTop = sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, with(density) { 3.dp.toPx() })
// Play's raised place: its centre 3dp under the sheet's edge, same left inset.
val raise = restRect?.let { (openTop + with(density) { 3.dp.toPx() } - buttonH / 2f) - it.top } ?: 0f
// The wall behind sinks back about the pane's centre.
Box(Modifier.fillMaxSize().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() }
// The dim; a tap on it (above the sheet) folds the sheet away.
if (shown || open) Box(
Modifier.fillMaxSize().graphicsLayer { alpha = sink.value }.background(Color.Black.copy(alpha = 0.42f))
.focusProperties { canFocus = false }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open && !launching) { close() },
)
// The sheet: inset at the sides, rounded at the top, no bottom edge - the pane's foot is its foot.
if (shown) {
val top = lerp(h, openTop, lift.value)
val sheetLeft = inset
val sheetRight = w - inset
val path = remember { Path() }
val rimPath = remember { Path() }
val corner = with(density) { SheetCorner.toPx() }
val rim = with(density) { 1.4.dp.toPx() }
val pill = restRect?.translate(0f, raise)
Canvas(Modifier.fillMaxSize()) {
val f = fold.value
val ft = foldTop.value
val l = if (pill != null) lerp(sheetLeft, pill.left, f) else sheetLeft
val r = if (pill != null) lerp(sheetRight, pill.right, f) else sheetRight
val t = if (pill != null) lerp(top, pill.top, ft) else top
val b = if (pill != null) lerp(h + corner, pill.bottom, ft) else h + corner
val c = if (pill != null) lerp(corner, with(density) { 12.dp.toPx() }, ft) else corner
val bc = if (ft > 0f) c else 0f
path.reset()
path.addRoundRect(RoundRect(l, t, r, b, CornerRadius(c), CornerRadius(c), CornerRadius(bc), CornerRadius(bc)))
val fill = lerp(colors.surface, pal.signal, 0.22f * hot.value)
drawPath(path, fill)
val rimColor = pal.signal.copy(alpha = lerp(0.55f, 0.9f, hot.value))
if (ft > 0f) drawPath(path, rimColor, style = Stroke(rim))
else {
// Sides and top only: the sheet runs on under the pane's foot.
rimPath.reset()
rimPath.moveTo(l, b)
rimPath.lineTo(l, t + c)
rimPath.arcTo(Rect(l, t, l + 2 * c, t + 2 * c), 180f, 90f, false)
rimPath.lineTo(r - c, t)
rimPath.arcTo(Rect(r - 2 * c, t, r, t + 2 * c), -90f, 90f, false)
rimPath.lineTo(r, b)
drawPath(rimPath, rimColor, style = Stroke(rim))
}
}
// What the sheet holds: the chips to the right of Play, the rows under them.
val chipsX = with(density) {
if (narrow || pill == null) 16.dp.toPx() else pill.right + 20.dp.toPx() - sheetLeft
}
val chipsY = with(density) { if (narrow && pill != null) (pill.bottom - openTop) + 10.dp.toPx() else ChipsTop.toPx() }
val rowsY = with(density) { if (narrow && pill != null) chipsY + 54.dp.toPx() else RowsTop.toPx() }
val sheetW = sheetRight - sheetLeft
Box(
Modifier
.offset { IntOffset(sheetLeft.roundToInt(), top.roundToInt()) }
.size(with(density) { sheetW.toDp() }, with(density) { (h - openTop).coerceAtLeast(0f).toDp() })
// Touches on the sheet stay on it; none reach the dim behind.
.pointerInput(Unit) { detectTapGestures { } }
.controllerBack { close() }
.bumpers(
onPrevious = { if (tab > 0) { travel = -1; tab-- } },
onNext = { if (tab < tabs.lastIndex) { travel = 1; tab++ } },
)
// A pad stays in the sheet until it folds; Left from it reaches Play, the handle.
.focusProperties { exit = { dir -> if (dir == FocusDirection.Left && ff != null) ff.primary else FocusRequester.Cancel } }
.focusGroup(),
) {
Box(
Modifier
.offset { IntOffset(chipsX.roundToInt(), chipsY.roundToInt()) }
.width(with(density) { (sheetW - chipsX - 16.dp.toPx()).coerceAtLeast(0f).toDp() })
.graphicsLayer { val v = pieces[0].value; alpha = v; translationY = (1f - v) * 6.dp.toPx() }
// A pull down on the chips folds the sheet; so does Up from them.
.pointerInput(Unit) {
var dy = 0f
detectVerticalDragGestures(onDragStart = { dy = 0f }) { change, amount ->
dy += amount
if (dy > 24.dp.toPx()) { change.consume(); close(); dy = Float.NEGATIVE_INFINITY }
}
}
.onPreviewKeyEvent { e ->
val k = e.nativeKeyEvent
if (k.keyCode == KeyEvent.KEYCODE_DPAD_UP) { if (k.action == KeyEvent.ACTION_DOWN) close(); true } else false
},
) {
SheetChips(tabs.map { it.second }, tab, firstChip) { i -> travel = if (i > tab) 1 else -1; tab = i }
}
Box(
Modifier
.offset { IntOffset(0, rowsY.roundToInt()) }
.fillMaxWidth()
.height(with(density) { (h - openTop - rowsY).coerceAtLeast(0f).toDp() }),
) {
AnimatedContent(
targetState = tab,
transitionSpec = {
val d = travel
val shift = with(density) { 24.dp.roundToPx() }
(slideInHorizontally(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240)))
.togetherWith(slideOutHorizontally(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240)))
},
label = "sheetTab",
) { t ->
val leaving = transition.targetState == androidx.compose.animation.EnterExitState.PostExit
val leavingNow = rememberUpdatedState(leaving)
CompositionLocalProvider(LocalSheetBeat provides remember { SheetBeat(pieces) }) {
Column(
Modifier.fillMaxSize()
// Focus never lands in rows on their way out.
.focusProperties { canFocus = !leavingNow.value }
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
) { rows(tabs[t].first) }
}
}
}
}
}
// The words and the button, where they always were; the button rides the sheet's edge.
Column(Modifier.align(Alignment.BottomStart).padding(start = start, bottom = bottom, end = 20.dp)) {
Box(Modifier.graphicsLayer { alpha = 1f - sink.value }) { words() }
Spacer(Modifier.height(18.dp))
Box(
Modifier
.onGloballyPositioned { rest = it.boundsInRoot() }
.graphicsLayer { translationY = raise * ride.value }
.onFocusChanged { playFocused = it.hasFocus }
.hoverable(playSrc)
// A pull up from Play opens the sheet.
.pointerInput(Unit) {
var dy = 0f
detectVerticalDragGestures(onDragStart = { dy = 0f }) { change, amount ->
dy += amount
if (dy < -24.dp.toPx() && !open) { change.consume(); open = true; dy = 0f }
}
}
.onPreviewKeyEvent { e ->
val k = e.nativeKeyEvent
if (k.keyCode != KeyEvent.KEYCODE_DPAD_DOWN) false
else {
if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) {
if (!open) open = true else runCatching { firstChip.requestFocus() }
}
true
}
},
) {
button {
if (launching) return@button
if (open || shown) { launching = true; open = false } else onPrimary()
}
}
}
// The lip, under Play: stretched by the pull, gone as the sheet lifts.
val r0 = restRect
if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f)) {
val lipH = 26.dp + pull.value.dp
Box(
Modifier
.offset { IntOffset((r0.left + 16.dp.toPx()).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) }
.graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) }
// 44dp to touch; the tab itself is the top 26.
.heightIn(min = 44.dp)
.pointerInput(Unit) {
var dy = 0f
detectVerticalDragGestures(onDragStart = { dy = 0f }) { change, amount ->
dy += amount
if (dy < -24.dp.toPx() && !open) { change.consume(); open = true; dy = 0f }
}
}
.focusProperties { canFocus = false }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button) { if (!open) open = true },
) {
Lip(lip(), lit, lipLit.value, lipH)
}
}
}
}
/** The lip's tab: what Play will do, a part lit while it says something new. */
@Composable
private fun Lip(parts: List<Pair<String, String>>, lit: Set<String>, glow: Float, height: Dp) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val shape = RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(height).clip(shape).background(colors.surface)
.drawBorderSidesAndFoot(pal.line, 14.dp)
.padding(start = 12.dp, end = 8.dp),
) {
parts.forEachIndexed { i, (k, v) ->
if (i > 0) Text(" · ", fontSize = 12.sp, color = colors.onSurfaceVariant)
Text(
v, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(4.dp))
.background(if (k in lit) pal.signal.copy(alpha = 0.25f * glow) else Color.Transparent)
.padding(horizontal = 2.dp),
)
}
Icon(Icons.Filled.KeyboardArrowDown, null, tint = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp).size(16.dp))
}
}
/** A 1dp outline down the sides and along the foot, none along the top (the lip tucks under Play). */
private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp): Modifier =
this.drawWithContent {
drawContent()
val c = corner.toPx()
val sw = 1.dp.toPx()
val p = Path().apply {
moveTo(sw / 2, 0f)
lineTo(sw / 2, size.height - c)
arcTo(Rect(sw / 2, size.height - 2 * c + sw / 2, 2 * c, size.height - sw / 2), 180f, -90f, false)
lineTo(size.width - c, size.height - sw / 2)
arcTo(Rect(size.width - 2 * c, size.height - 2 * c + sw / 2, size.width - sw / 2, size.height - sw / 2), 90f, -90f, false)
lineTo(size.width - sw / 2, 0f)
}
drawPath(p, color, style = Stroke(sw))
}
/**
* The sheet's chips: equal capsules with one selection gliding under them (the stores' chip row),
* LB and RB a chip at a time, a tap or A on a far one a hop as a drop. [first] takes focus as the
* sheet opens. Too narrow for equal chips, the row scrolls.
*/
@Composable
internal fun SheetChips(labels: List<String>, selected: Int, first: FocusRequester? = null, onPick: (Int) -> Unit) {
val pal = LocalPalette.current
val bounds = remember { mutableStateMapOf<Int, Rect>() }
val tint = rememberUpdatedState(pal.signal)
BoxWithConstraints(Modifier.fillMaxWidth()) {
// Equal chips while every label fits one; otherwise each its own width, and the row scrolls.
val equal = maxWidth / labels.size.coerceAtLeast(1) >= 96.dp
val scroll = rememberScrollState()
Box(if (equal) Modifier.fillMaxWidth() else Modifier.horizontalScroll(scroll)) {
ChipGlide(selected, bounds[selected], tint)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = if (equal) Modifier.fillMaxWidth() else Modifier) {
labels.forEachIndexed { i, label ->
val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = 64.dp))
.onGloballyPositioned { bounds[i] = it.boundsInParent() }
.then(if (i == selected && first != null) Modifier.focusRequester(first) else Modifier)
SheetChip(label, i == selected, m) { onPick(i) }
}
}
}
}
}
@Composable
private fun SheetChip(label: String, on: Boolean, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(shape)
.background(if (on) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f))
.glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/**
* A read-only line that is a link: [title], [value] under it, and "[link] ↗" at the right in the
* signal blue - not a picker, so no value box. A (or a tap) follows it. [id] is its focus key.
*/
@Composable
internal fun LinkRow(title: String, value: String, link: String, id: String, arrow: String = "↗", onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(12.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().paneItem(id).hopSource(id).heightIn(min = 56.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.glideBorder(hot, shape, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 11.dp),
) {
Column(Modifier.weight(1f).padding(end = 12.dp)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(value, fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text("$link $arrow", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -23,6 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionPrefs
@@ -158,303 +160,166 @@ class ModeSettingsActions(
val onDismiss: () -> Unit,
)
private enum class ModeSettingsTab(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),
/**
* The chips of a mode's lifted sheet, in order: Steam's six; the desktop's Display, Controls and
* Session (its Files chip is the Desktop page's own).
*/
internal fun modeSheetTabs(steam: Boolean): List<String> =
if (steam) listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.AUDIO, SheetTab.SESSION, SheetTab.LIBRARY, SheetTab.STEAM)
else listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.SESSION)
/** A sheet tab's chip label. */
@Composable
internal fun sheetTabLabel(tab: String): String = when (tab) {
SheetTab.DISPLAY -> stringResource(R.string.drawer_page_display)
SheetTab.CONTROLS -> stringResource(R.string.drawer_page_controls)
SheetTab.AUDIO -> stringResource(R.string.mode_audio)
SheetTab.SESSION -> stringResource(R.string.mode_session)
SheetTab.LIBRARY -> stringResource(R.string.stores_tab_library)
SheetTab.STEAM -> stringResource(R.string.mode_tab_steam)
else -> stringResource(R.string.setup_tool_files)
}
/**
* Rows the front end adds to a sheet's tabs: frame generation (Display), Performance (Session) and
* the Graphics drivers link to Components (Display, given the drivers' summary).
*/
class SheetExtras(
val frameGen: @Composable () -> Unit = {},
val performance: @Composable () -> Unit = {},
val drivers: @Composable (summary: String?) -> Unit = {},
)
/** One tab of a mode's lifted sheet: the rows the settings page had, grouped by what they are for. */
@Composable
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SheetExtras = SheetExtras()) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
val tabs = if (steam) ModeSettingsTab.entries else listOf(
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
)
val tab = tabs[tabIndex]
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
fun selectTab(index: Int) { host.open = null; tabIndex = index }
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
androidx.compose.runtime.withFrameNanos { }
runCatching {
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
else tabFocus[tabIndex].requestFocus()
if (tab == SheetTab.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)
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(LocalContext.current), 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(LocalContext.current), 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,
)
}
extras.frameGen()
extras.drivers(s.gpuDrivers)
}
if (tab == SheetTab.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_TOUCH to stringResource(R.string.osc_steam_touch),
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 && s.unfoldedControls != null) ToggleRow(
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint),
s.unfoldedControls, onChange = a.onUnfoldedControls,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
SettingsPage(
host,
title = null,
onBack = a.onDismiss,
scroll = scrolls[tabIndex],
modifier = Modifier.bumpers(
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
onNext = { selectTab((tabIndex + 1) % tabs.size) },
),
header = {
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
},
) {
if (tab == ModeSettingsTab.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(LocalContext.current), 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(LocalContext.current), 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 ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() }
}
}
}
if (tab == ModeSettingsTab.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_TOUCH to stringResource(R.string.osc_steam_touch),
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 && s.unfoldedControls != null) ToggleRow(
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint),
s.unfoldedControls, onChange = a.onUnfoldedControls,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
if (tab == ModeSettingsTab.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),
) + if (steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
)
}
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 && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
s.deckyStage != null -> s.deckyStage
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
}
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
val action = when {
s.deckyStage != null -> stringResource(R.string.common_working)
s.deckyChecking -> stringResource(R.string.common_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
else -> stringResource(R.string.common_check)
}
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
action,
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
) {
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
else s.deckyLatestRelease?.let(a.onDeckyInstall)
}
if (s.deckyInstalled != null) SecondaryButton(
stringResource(R.string.common_uninstall),
enabled = s.deckyStage == null && !s.deckySessionRunning,
) { confirmDeckyRemoval = true }
}
}
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
else stringResource(R.string.mode_decky_off),
s.deckyEnabled,
enabled = !s.deckySessionRunning && s.deckyStage == null,
onChange = a.onDeckyEnabled,
)
SettingsRow(
stringResource(R.string.mode_decky_plugins),
stringResource(R.string.mode_decky_plugins_hint),
) {
SecondaryButton(
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
}
if (s.deckyStage != null && s.deckyPercent >= 0) {
LinearProgressIndicator(
progress = { s.deckyPercent / 100f },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
if (steam && tab == ModeSettingsTab.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),
s.steamDeckMode, onChange = a.onSteamDeckMode,
)
if (s.steamDeckMode) ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
else ChoiceRow(
host, "channel", stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_hint),
listOf("publicbeta" to stringResource(R.string.mode_branch_public), "steamdeck_publicbeta" to stringResource(R.string.mode_branch_deck_beta)), s.steamChannel,
note = stringResource(R.string.mode_branch_note),
onPick = a.onSteamChannel,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (steam && tab == ModeSettingsTab.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)
else -> stringResource(R.string.mode_wifi_discovery_hint)
}
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
}
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
host.open = null
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
}
}
}
}
if (steam && tab == ModeSettingsTab.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),
) {
SegmentedTabs(
listOf(
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
),
s.syncBackend,
) { id -> host.open = null; a.onSyncBackend(id) }
}
ChoiceRow(
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
GameEnvironmentRow()
}
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
if (tab == SheetTab.AUDIO && steam) {
if (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)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
}
if (tab == SheetTab.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),
) + if (steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
)
}
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,
)
}
extras.performance()
}
if (tab == SheetTab.LIBRARY && steam) {
if (s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage }
// Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with.
class StorageChoice(val path: String, val label: String, val chip: String, val saved: String)
@@ -496,6 +361,132 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onChange = a.onStorageDiagnostics,
)
}
if (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),
) {
SegmentedTabs(
listOf(
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
),
s.syncBackend,
) { id -> host.open = null; a.onSyncBackend(id) }
}
ChoiceRow(
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
GameEnvironmentRow()
}
}
if (tab == SheetTab.STEAM && steam) {
SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
s.deckyStage != null -> s.deckyStage
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
}
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
val action = when {
s.deckyStage != null -> stringResource(R.string.common_working)
s.deckyChecking -> stringResource(R.string.common_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
else -> stringResource(R.string.common_check)
}
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
action,
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
) {
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
else s.deckyLatestRelease?.let(a.onDeckyInstall)
}
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
}
}
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
else stringResource(R.string.mode_decky_off),
s.deckyEnabled,
enabled = !s.deckySessionRunning && s.deckyStage == null,
onChange = a.onDeckyEnabled,
)
SettingsRow(
stringResource(R.string.mode_decky_plugins),
stringResource(R.string.mode_decky_plugins_hint),
) {
SecondaryButton(
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
}
if (s.deckyStage != null && s.deckyPercent >= 0) {
LinearProgressIndicator(
progress = { s.deckyPercent / 100f },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
if (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),
s.steamDeckMode, onChange = a.onSteamDeckMode,
)
if (s.steamDeckMode) ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
else ChoiceRow(
host, "channel", stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_hint),
listOf("publicbeta" to stringResource(R.string.mode_branch_public), "steamdeck_publicbeta" to stringResource(R.string.mode_branch_deck_beta)), s.steamChannel,
note = stringResource(R.string.mode_branch_note),
onPick = a.onSteamChannel,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (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)
else -> stringResource(R.string.mode_wifi_discovery_hint)
}
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
}
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
host.open = null
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
}
}
}
}
}
if (explainWifiDiscovery) AlertDialog(
onDismissRequest = { explainWifiDiscovery = false },
@@ -510,15 +501,36 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
TextButton(onClick = { explainWifiDiscovery = false }) { Text(stringResource(R.string.common_cancel)) }
},
)
if (confirmDeckyRemoval) AlertDialog(
onDismissRequest = { confirmDeckyRemoval = false },
title = { Text(stringResource(R.string.mode_decky_remove_title)) },
text = { Text(stringResource(R.string.mode_decky_remove_text)) },
confirmButton = {
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text(stringResource(R.string.common_uninstall)) }
},
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text(stringResource(R.string.common_cancel)) } },
)
}
/**
* Uninstall for Decky Loader: are you sure steps out of it in red, Cancel first, so a stray A
* costs nothing.
*/
@Composable
private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) {
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.common_uninstall)
val title = stringResource(R.string.mode_decky_remove_title)
val note = stringResource(R.string.mode_decky_remove_text)
val cancel = stringResource(R.string.common_cancel)
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
androidx.compose.foundation.layout.Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
handle = { Text(label, fontSize = 15.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(title)
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.stepItem(2)) {
StepChoice(cancel, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
}
})
}
}
}
/** Width × height for the session, with the common handheld shapes one tap away. */
@@ -423,6 +423,8 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable ColumnScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// In a lifted sheet each group fades and rises in on its own beat.
Column(Modifier.fillMaxWidth().sheetPiece()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 10.dp),
@@ -432,6 +434,7 @@ fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable
Box(modifier = Modifier.weight(1f).height(1.dp).background(pal.line))
}
Column(modifier = Modifier.fillMaxWidth().clip(GroupShape).background(colors.surface).border(1.dp, pal.line, GroupShape)) { content() }
}
}
/**
@@ -0,0 +1,85 @@
package com.droiddeck.launcher.ui
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionDisplay
import com.droiddeck.launcher.session.SessionPrefs
// The rows the lifted sheets add to what the session settings already had: frame generation,
// Performance and the link to the GPU drivers on Components.
/**
* What a mode's lip says: its session size, then (when [full]) its frame-rate cap and upscaler,
* then its touch mode, each left out at its default.
*/
internal fun lipFor(ctx: Context, mode: String, full: Boolean): List<Pair<String, String>> {
val panel = SessionDisplay.panelSize(ctx)
val size = SessionDisplay.resolveChoice(panel, SessionPrefs.resolutionChoice(ctx, mode, panel))
val fps = if (full) SessionPrefs.fpsLimit(ctx, mode) else 0
val upscaler = if (!full) null else SessionPrefs.upscaler(ctx).takeIf { it != 0 }
?.let { m -> SessionPrefs.upscalerChoices(ctx).firstOrNull { it.first == m }?.second }
val touch = when (SessionPrefs.touchMode(ctx)) {
SessionPrefs.TOUCH_PAD -> ctx.getString(R.string.mode_touch_touchpad)
SessionPrefs.TOUCH_DIRECT -> ctx.getString(R.string.mode_touch_direct)
SessionPrefs.TOUCH_OFF -> ctx.getString(R.string.lip_touch_off)
else -> null
}
return lipParts(size, fps, { ctx.getString(R.string.lip_fps, it) }, upscaler, touch)
}
/** Frame generation, moved here from Setup: it is how Play's games are drawn. */
@Composable
internal fun FrameGenGroup(s: FrontEndState, a: FrontEndActions) {
val host = rememberMenuHost()
SettingsGroup(stringResource(R.string.frame_gen_title)) {
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint), highlighted = host.open == "fg") {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless)
}
}
}
}
/**
* Performance (CPU core assignment), opened from the sheet's Session chip: its page floods out of
* this row, and Back draws it into the row again with the sheet lifted where it was.
*/
@Composable
internal fun PerformanceLink(a: FrontEndActions, rail: String, tab: String) {
val id = "sheet:performance"
val title = stringResource(R.string.setup_tool_performance)
SettingsGroup(title) {
LinkRow(title, stringResource(R.string.setup_tool_performance_hint), stringResource(R.string.store_open), id, arrow = "›") {
Hops.sources[id]?.let { at ->
PageOrigin.mark(Origin(at, 12f * Hops.density))
Hops.restore = HopOrigin(rail, HOP_SURFACE_SHEET, tab, id, at, "", dest = rail, page = "performance")
}
a.onPerformance()
}
}
}
/**
* The GPU drivers in use, read only, and a link to where they are set: A hops to Components with
* the GPU drivers layer chosen, and B there comes back to this row.
*/
@Composable
internal fun DriversLink(a: FrontEndActions, summary: String?, rail: String, back: String) {
val id = "sheet:gpu-drivers"
SettingsGroup(stringResource(R.string.mode_drivers)) {
LinkRow(
stringResource(R.string.sheet_graphics_drivers), summary ?: stringResource(R.string.common_auto),
stringResource(R.string.rail_components), id,
) {
val at = Hops.sources[id]
if (at == null) a.onComponentsTab(GPU_TAB)
else Hops.go(HopOrigin(rail, HOP_SURFACE_SHEET, SheetTab.DISPLAY, id, at, back, dest = "components", page = "components")) {
a.onComponentsTab(GPU_TAB)
}
}
}
}
@@ -67,36 +67,48 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
// came out differently from one read of the library to the next moved every capsule.
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
val narrow = LocalNarrowPane.current
val colors = MaterialTheme.colorScheme
val ctx = androidx.compose.ui.platform.LocalContext.current
// Where the words and buttons sit, for the empty library's wandering mark to keep off.
var words by remember { mutableStateOf<Rect?>(null) }
Box(modifier = modifier.fillMaxSize().clipToBounds()) {
when {
games.isEmpty() -> EmptyLibrary(avoid = words) { WallFade() }
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
else -> {
CapsuleWall(games)
WallFade()
val mode = com.droiddeck.launcher.session.SessionService.MODE_STEAM
val tabs = modeSheetTabs(steam = true).map { it to sheetTabLabel(it) }
val back = stringResource(R.string.hop_from_play)
val extras = SheetExtras(
frameGen = { FrameGenGroup(s, a) },
performance = { PerformanceLink(a, "steam", SheetTab.SESSION) },
drivers = { summary -> DriversLink(a, summary, "steam", back) },
)
LiftHero(
key = "steam", tabs = tabs, lip = { lipFor(ctx, mode, full = true) },
onOpen = a.onSteamSettings, onPrimary = a.onPlay,
modifier = modifier.clipToBounds(),
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
background = {
when {
games.isEmpty() -> EmptyLibrary(avoid = words) { WallFade() }
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
else -> {
CapsuleWall(games)
WallFade()
}
}
}
Column(
modifier = Modifier.align(Alignment.BottomStart)
.padding(start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, end = 20.dp)
.onPlaced { words = it.boundsInParent() },
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
},
words = {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.onPlaced { words = it.boundsInParent() },
) {
Wordmark(if (narrow) 30.dp else 40.dp)
// Said only when something stands between Play and Steam; a ready runtime needs no words.
if (s.busy || !s.ready || (s.available != null && s.available != s.installed)) RuntimeChip(s)
}
Spacer(Modifier.height(18.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
// Enabled without a runtime: the session's loading screen installs it first.
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = a.onPlay)
Cog(size = 54.dp, onClick = a.onSteamSettings)
}
}
}
},
button = { click ->
// Enabled without a runtime: the session's loading screen installs it first.
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = click)
},
rows = { tab -> a.modeSheet(mode, tab, extras) },
)
}
/** The wall fades out behind the words, and toward the bottom where the buttons sit. */
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
// The front end's confirms and short lists, each stepping out of the control that asked for it
// (StepOut): a sheet's Uninstall, a Components swap, a remove. The Stores page keeps its own, in
// StoresMotion; everything else asks here and is drawn over the whole screen.
internal object Steps {
/** The confirm or list stepping out of a control now, and whether it is open. */
var step by mutableStateOf<StepAsk?>(null)
private set
var open by mutableStateOf(false)
private set
fun ask(a: StepAsk) {
step?.onDismiss()
step = a
open = true
}
/** Folds the current one back into its control. */
fun fold() { open = false }
/** Drops it at once: the control it came from has gone. */
fun drop() {
step?.let { step = null; it.onDismiss() }
open = false
}
internal fun closed(a: StepAsk) {
if (step === a) step = null
a.onDismiss()
}
}
/** [Steps] drawn over an element at [origin] (root px) that fills the screen. */
@Composable
internal fun StepsLayer(origin: Offset) {
val ask = Steps.step ?: return
key(ask) {
StepOut(
open = Steps.open, pill = ask.anchor.translate(-origin), side = ask.side, accent = ask.accent,
onDismiss = { Steps.fold() }, onClosed = { Steps.closed(ask) },
pillCorner = ask.pillCorner, maxWidth = ask.maxWidth, items = ask.items, handle = ask.handle, content = ask.content,
)
}
}
@@ -152,6 +152,7 @@ internal class StepAsk(
val pillCorner: androidx.compose.ui.unit.Dp? = null,
val items: Int = 4,
val onDismiss: () -> Unit = {},
val maxWidth: androidx.compose.ui.unit.Dp = 300.dp,
val handle: @Composable () -> Unit,
val content: @Composable StepOutScope.() -> Unit,
)
+4
View File
@@ -2176,4 +2176,8 @@
<string name="hop_from_games">the game</string>
<string name="hop_from_play">Play settings</string>
<string name="hop_from_desktop">Desktop settings</string>
<!-- The lifted settings sheets under Play and Open desktop. -->
<string name="lip_fps">%1$d fps</string>
<string name="lip_touch_off">Touch off</string>
<string name="sheet_graphics_drivers">Graphics drivers</string>
</resources>
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LiftSheetTest {
private val fps: (Int) -> String = { "$it fps" }
@Test fun theLipSaysEverythingThatIsNotDefault() {
val parts = lipParts(1280 to 720, 60, fps, "FSR 1", "Touchpad")
assertEquals("1280×720 · 60 fps · FSR 1 · Touchpad", parts.joinToString(" · ") { it.second })
}
@Test fun defaultsAreLeftOut() {
assertEquals(listOf("size" to "1920×1080"), lipParts(1920 to 1080, 0, fps, null, null))
assertEquals(listOf("size", "touch"), lipParts(1920 to 1080, 0, fps, null, "Touchpad").map { it.first })
}
@Test fun nothingIsLitTheFirstTime() {
assertEquals(emptySet<String>(), lipChanged(null, lipParts(1280 to 720, 60, fps, null, null)))
}
@Test fun aChangedOrNewPartIsLit() {
val seen = lipParts(1280 to 720, 0, fps, null, "Touchpad").toMap()
assertEquals(setOf("size", "fps"), lipChanged(seen, lipParts(1920 to 1080, 60, fps, null, "Touchpad")))
}
@Test fun theSheetTopIs82dpDownA360dpPane() {
assertEquals(82f, sheetTop(360f, 56f, 16f, 3f), 0.01f)
assertEquals(164f, sheetTop(720f, 56f, 16f, 3f), 0.01f)
}
@Test fun theButtonKeeps16dpAboveIt() {
// A short pane: the edge comes down so the button riding it still clears the top by 16.
val top = sheetTop(150f, 56f, 16f, 3f)
assertEquals(41f, top, 0.01f)
assertEquals(16f, top + 3f - 56f / 2f, 0.01f)
}
}