mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat(ui): lift the Play sheet out from under Play
This commit is contained in:
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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user