From dde08414fcc02eb829220ad5777015ead683c0df Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:05:21 -0400 Subject: [PATCH 1/6] feat: big ass redesign: Carry the stores' motion and layout into Steam settings, Components, Setup, Flathub and Desktop (#548) --- .../com/droiddeck/launcher/MainActivity.kt | 126 +- .../launcher/runtime/DesktopCatalog.kt | 3 + .../launcher/runtime/DesktopRemoval.kt | 8 + .../launcher/session/SessionPrefs.kt | 14 - .../droiddeck/launcher/store/StoreState.kt | 3 + .../droiddeck/launcher/ui/ComponentsPage.kt | 1032 +++++++++++++---- .../com/droiddeck/launcher/ui/DesktopPage.kt | 455 ++++++++ .../com/droiddeck/launcher/ui/FrontEndArt.kt | 13 + .../droiddeck/launcher/ui/FrontEndContent.kt | 354 +++--- .../com/droiddeck/launcher/ui/FrontEndRail.kt | 59 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 90 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 324 +++--- .../droiddeck/launcher/ui/GpuDriversPanel.kt | 271 +---- .../java/com/droiddeck/launcher/ui/Hop.kt | 174 +++ .../com/droiddeck/launcher/ui/LiftSheet.kt | 805 +++++++++++++ .../launcher/ui/ModeSettingsDialog.kt | 587 +++++----- .../com/droiddeck/launcher/ui/PageFlood.kt | 46 +- .../com/droiddeck/launcher/ui/ProtonPage.kt | 88 -- .../droiddeck/launcher/ui/SettingsWidgets.kt | 3 + .../com/droiddeck/launcher/ui/SharedMotion.kt | 237 ++++ .../com/droiddeck/launcher/ui/SheetRows.kt | 63 + .../com/droiddeck/launcher/ui/SteamHome.kt | 113 +- .../java/com/droiddeck/launcher/ui/StepOut.kt | 7 +- .../java/com/droiddeck/launcher/ui/Steps.kt | 55 + .../com/droiddeck/launcher/ui/StoreChips.kt | 8 + .../com/droiddeck/launcher/ui/StorePage.kt | 821 +++++++++---- .../droiddeck/launcher/ui/StoresDownloads.kt | 56 +- .../com/droiddeck/launcher/ui/StoresMotion.kt | 64 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 95 +- app/src/main/res/values-es/strings.xml | 3 - app/src/main/res/values-fr/strings.xml | 3 - app/src/main/res/values-ja/strings.xml | 3 - app/src/main/res/values-ko/strings.xml | 3 - app/src/main/res/values-ru/strings.xml | 3 - app/src/main/res/values-zh-rCN/strings.xml | 3 - app/src/main/res/values-zh-rHK/strings.xml | 3 - app/src/main/res/values-zh-rTW/strings.xml | 3 - app/src/main/res/values/strings.xml | 50 +- .../droiddeck/launcher/ui/GlideShapeTest.kt | 29 + .../com/droiddeck/launcher/ui/InPaneTest.kt | 20 + .../droiddeck/launcher/ui/LayerStepTest.kt | 24 + .../droiddeck/launcher/ui/LiftSheetTest.kt | 18 + .../com/droiddeck/launcher/ui/PageExitTest.kt | 31 + 43 files changed, 4351 insertions(+), 1819 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/Hop.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt delete mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SheetRows.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/Steps.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/GlideShapeTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/LayerStepTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/PageExitTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 1db068128..58d8599f5 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -46,7 +46,6 @@ import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SteamRepair -import com.droiddeck.launcher.ui.ProtonPage import com.droiddeck.launcher.core.CpuCores import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus @@ -55,7 +54,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 @@ -124,7 +122,6 @@ class MainActivity : ComponentActivity() { private var steamRepairQueued by mutableStateOf(false) private var mangoapp by mutableStateOf(true) private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK) - private var showProtons by mutableStateOf(false) // Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager). private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) @@ -166,8 +163,6 @@ class MainActivity : ComponentActivity() { private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) private var launcherFullscreen by mutableStateOf(true) private var animationsEnabled by mutableStateOf(true) - private var storeEnabled by mutableStateOf(false) - private var gameStoresEnabled by mutableStateOf(false) private var gameStoresSpeedTier by mutableStateOf("fast") private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY) /** A page a notification asked for ("stores:downloads", "app:"), until the front end has gone there. */ @@ -338,7 +333,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(null) private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION) private var fexPreset by mutableStateOf("") @@ -539,8 +534,6 @@ class MainActivity : ComponentActivity() { requestedOrientation = orientation launcherFullscreen = SessionPrefs.launcherFullscreen(this) animationsEnabled = SessionPrefs.animationsEnabled(this) - storeEnabled = SessionPrefs.storeEnabled(this) - gameStoresEnabled = SessionPrefs.gameStoresEnabled(this) gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this) storesOpenTab = SessionPrefs.storesOpenTab(this) storesShowMature = SessionPrefs.storesShowMature(this) @@ -549,11 +542,8 @@ 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() } } showMapping -> { { MappingHost() } } else -> null @@ -572,7 +562,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 (showComponents) "components" else if (showMapping) "controller-mapping" else null, theme = theme, language = com.droiddeck.launcher.core.AppLanguage.chosen(this), appScale = appScale, @@ -597,8 +587,6 @@ class MainActivity : ComponentActivity() { orientation = orientation, launcherFullscreen = launcherFullscreen, animationsEnabled = animationsEnabled, - storeEnabled = storeEnabled, - gameStoresEnabled = gameStoresEnabled, gameStoresSpeedTier = gameStoresSpeedTier, storesOpenTab = storesOpenTab, navigate = navRequest, @@ -683,9 +671,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() }, @@ -698,10 +687,11 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, + onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) }, // A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread. onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } }, onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } }, - onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true }, + onPerformance = { refreshCores(); showComponents = false; showMapping = false; showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) }, onBrowseFiles = { dir -> @@ -736,7 +726,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; showComponents = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onLanguage = { tag -> if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) { @@ -759,8 +749,6 @@ class MainActivity : ComponentActivity() { launcherFullscreen = on applyLauncherFullscreen() }, - onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on }, - onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) }, onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier }, onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab }, onNavigated = { navRequest = null }, @@ -846,7 +834,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; showComponents = false; showMapping = true }, onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, ), ), @@ -966,14 +954,12 @@ class MainActivity : ComponentActivity() { } } - /** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */ + /** On the Components page the pad's LB / RB step through the stack's layers, wrapping around. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS - val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1 - components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size] + components.compComp = com.droiddeck.launcher.ui.layerStep(components.compComp, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1) } return true } @@ -995,7 +981,7 @@ class MainActivity : ComponentActivity() { refreshWifiDiscovery() // Back from a store's sign-in page: the Stores chips show the account at once. A store // install finishing while this screen is up rebuilds the Games list through the listener. - if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this) + com.droiddeck.launcher.stores.StoresState.refresh(this) com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() } com.droiddeck.launcher.ui.Motion.refresh(this) // Back from a session stopped behind a flood: open on its blue, before the first frame. @@ -1126,14 +1112,8 @@ class MainActivity : ComponentActivity() { }, "game-saves-action").start() } - private fun openProtons() { - settingsMode = null - showPerformance = false - showComponents = false - showMapping = false - showProtons = true - protons.refreshProtons() - } + /** The Protons DroidDeck installs are the Components page's Proton layer. */ + private fun openProtons() = openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.PROTON_TAB) private fun refreshController() { controllerSettings = ControllerPrefs.read(this) @@ -1153,13 +1133,18 @@ 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 showComponents = true + // The Proton layer's options for every Proton, as they are now. + fexPreset = SessionPrefs.fexPreset(this) + forceSsbs = SessionPrefs.forceSsbs(this) + fastSync = SessionPrefs.fastSync(this) + fsyncFirst = SessionPrefs.fsyncFirst(this) + syncFallback = SessionPrefs.syncFallback(this) components.refreshComponents(snapshotFirst = true) drivers.refreshDrivers() + protons.refreshProtons() } private fun importComponent(uri: Uri) { @@ -1193,7 +1178,8 @@ class MainActivity : ComponentActivity() { onDownload = { components.downloadComponent(it) }, onRefresh = { components.refreshComponentCatalog() }, onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) }, - onBack = { showComponents = false }, + // Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came. + onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false }, gpu = drivers.state(), gpuActions = com.droiddeck.launcher.ui.GpuDriversActions( onAuto = { on -> drivers.setMode(on) }, @@ -1210,23 +1196,31 @@ class MainActivity : ComponentActivity() { onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) }, onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() }, ), + protons = com.droiddeck.launcher.ui.ProtonsState( + rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent, + runtimeReady = ready && !busy, sessionRunning = SessionState.running, + ), + protonActions = com.droiddeck.launcher.ui.ProtonsActions( + onInstall = { id -> protons.installProton(id) }, + onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() }, + onRemove = { id -> protons.removeProton(id) }, + ), + protonOptions = { + com.droiddeck.launcher.ui.ProtonOptions( + syncBackend = SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback), fexPreset = fexPreset, forceSsbs = forceSsbs, + onSyncBackend = { id -> + SessionPrefs.setSyncBackend(this, id) + fastSync = SessionPrefs.fastSync(this) + fsyncFirst = SessionPrefs.fsyncFirst(this) + syncFallback = SessionPrefs.syncFallback(this) + }, + onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id }, + onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on }, + ) + }, ) - } - - @Composable - private fun ProtonHost() { - ProtonPage( - rows = protons.protonRows, - busyId = protons.protonBusyId, - stage = protons.protonStage, - percent = protons.protonPercent, - runtimeReady = ready && !busy, - sessionRunning = SessionState.running, - onInstall = { id -> protons.installProton(id) }, - onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() }, - onRemove = { id -> protons.removeProton(id) }, - onBack = { showProtons = false }, - ) + // A Proton installed or removed changes the stack's list of Protons. + androidx.compose.runtime.LaunchedEffect(protons.protonBusyId) { if (protons.protonBusyId == null) components.refreshComponents() } } private fun refreshPackages() { @@ -1273,8 +1267,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), @@ -1394,8 +1390,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 +1493,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) @@ -1530,8 +1523,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) @@ -1607,7 +1600,10 @@ class MainActivity : ComponentActivity() { val scanGeneration = ++libraryScanGeneration if (shortcutPicker) shortcutLibraryScanning = true Thread({ - // Steam's own games first, from their manifests alone (quick), beside the added games + // The emulators first: they need no walk of the games, and Desktop's dock is made of them. + val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null } + ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus } + // Steam's own games next, from their manifests alone (quick), beside the added games // already listed: a game Steam installed during the session shows as the session ends, // without waiting for the added games' walk below. Its Windows components' list is // queued then too, on the low-priority worker, so its page is usually ready. @@ -1628,7 +1624,6 @@ class MainActivity : ComponentActivity() { // so a partial list first would shuffle every capsule when the rest arrived. // LibraryCache covers the wait. val games = if (ready) Library.launchableGames(this) else emptyList() - val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null } val all = games.distinctBy { it.gameId } if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all) // Once per app start: the Steam games' Windows components from Steam's lists. @@ -1643,7 +1638,6 @@ class MainActivity : ComponentActivity() { ui.post { if (scanGeneration == libraryScanGeneration) { steamGames = all - emulatorList = emus shortcutLibraryScanning = false handleGameLink(all) } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index f24b267cd..971608106 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -110,6 +110,9 @@ object DesktopCatalog { return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists() } + /** The installed desktop's size on disk; off the main thread. */ + fun desktopSize(context: Context): Long = DesktopRemoval.installedSize(context) + fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) = DesktopRemoval.cleanLegacy(context, listener) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt index 3f3b493e5..09c93d73d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt @@ -19,6 +19,14 @@ internal object DesktopRemoval { private fun protected(context: Context, root: File) = DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip") + /** + * What the desktop's own files take on disk, from the same inventory its removal uses (each + * file's size); the bundled apps' and emulators' shared files are not counted. Reads an asset: + * call it off the main thread. + */ + fun installedSize(context: Context): Long = + lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L } + /** Called before a guest starts or just after KDE extraction; retries after a process death. */ fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) { val root = LinuxRuntime.rootDir(context) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index cb2e1cbd6..5e1387cab 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -68,20 +68,6 @@ object SessionPrefs { prefs(context).edit().putBoolean("animations", on).apply() } - /** The Flathub Store (beta): its rail item. Off by default. */ - fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false) - - fun setStoreEnabled(context: Context, on: Boolean) { - prefs(context).edit().putBoolean("storeEnabled", on).apply() - } - - /** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */ - fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false) - - fun setGameStoresEnabled(context: Context, on: Boolean) { - prefs(context).edit().putBoolean("gameStoresEnabled", on).apply() - } - /** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */ fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast" diff --git a/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt b/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt index b2c35911a..cd47019d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt +++ b/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt @@ -48,6 +48,9 @@ object StoreState { private set var searching by mutableStateOf(false) private set + + /** An app whose Flathub page was asked for from outside the store (its card on Desktop), once. */ + var openRequest by mutableStateOf(null) private var searchSeq = 0 val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 47e38d76d..e80156695 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -1,21 +1,27 @@ package com.droiddeck.launcher.ui -import androidx.compose.ui.platform.LocalContext -import com.droiddeck.launcher.R -import androidx.compose.ui.res.stringResource import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -23,16 +29,13 @@ import androidx.compose.foundation.layout.heightIn 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.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Refresh -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -41,28 +44,60 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +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.dp import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.session.ComponentsManager.CatalogItem import com.droiddeck.launcher.session.ComponentsManager.Snapshot +import kotlinx.coroutines.delay import java.text.DateFormat import java.util.Date +/** The Components page's layer for the GPU drivers, under every Proton's. */ +const val GPU_TAB = "gpu" + +/** The Components page's layer for the Proton itself, the one the indented layers belong to. */ +const val PROTON_TAB = "proton" + +/** The stack's layers, top to bottom: LB / RB and the d-pad walk them in this order. */ +internal val LAYERS = listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB) + +/** The layer [by] steps from [current] (LB -1, RB +1), wrapping round the stack. */ +internal fun layerStep(current: String, by: Int): String { + val i = LAYERS.indexOf(current).coerceAtLeast(0) + return LAYERS[((i + by) % LAYERS.size + LAYERS.size) % LAYERS.size] +} + /** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */ private class InstalledItem( val name: String, val detail: String, val tag: String, @@ -74,15 +109,18 @@ private class InstalledItem( ) private val GOLD = Color(0xFFF2C66D) +private val QUEUED = Color(0xFFFFB86B) private val ROW_SHAPE = RoundedCornerShape(10.dp) +private val LAYER_SHAPE = RoundedCornerShape(12.dp) /** - * FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry. - * A title row with the Nightlies refresh, then the Proton and the component (LB / RB turn it), what - * is in use, and the installed packages beside the ones on the Nightlies - stacked when the page is - * narrow. Tapping an installed row swaps it in; the refresh button is the only thing that goes online. + * Everything a game runs on, as one stack: the Proton, its DXVK, VKD3D-Proton and FEX indented + * under it because they belong to it, then the GPU drivers that every Proton shares. One selection + * glides along the stack (LB / RB, or the d-pad in it); beside it a detail frame that never moves - + * what is in use, what is installed and what is available, and what else can be done - only its + * rows trade places. A swap is asked for by stepping out of its row, and the row flies home to its + * layer. On a narrow page the stack is the page, and a layer's detail floods out of its card. */ -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable fun ComponentsPage( snapshot: Snapshot?, @@ -105,212 +143,338 @@ fun ComponentsPage( onImport: () -> Unit, onBack: () -> Unit, requestInitialFocus: Boolean = true, - /** The GPU drivers tab ([GPU_TAB]): what it shows and does. */ + /** The GPU drivers layer ([GPU_TAB]): what it shows and does. */ gpu: GpuDriversState = GpuDriversState(), gpuActions: GpuDriversActions = GpuDriversActions(), + /** The Proton layer ([PROTON_TAB]): the Protons DroidDeck can install, and installing them. */ + protons: ProtonsState = ProtonsState(), + protonActions: ProtonsActions = ProtonsActions(), + /** The Proton layer's options for every Proton (sync, FEX preset, SSBS, environment). */ + protonOptions: @Composable () -> Unit = {}, ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val narrow = LocalNarrowPane.current - BackHandler(onBack = onBack) - var confirm by remember { mutableStateOf Unit>?>(null) } - var confirmTitle by remember { mutableStateOf("") } - var about by remember { mutableStateOf(false) } - var protonMenu by remember { mutableStateOf(false) } - var confirmVerb by remember { mutableStateOf(R.string.comp_swap) } val ctx = LocalContext.current - fun ask(title: String, body: String, verb: Int, action: () -> Unit) { confirmTitle = title; confirmVerb = verb; confirm = body to action } - // The GPU drivers tab's Advanced pages: one driver list on its own, full page. - var driverPage by remember { mutableStateOf(null) } - val gpuTab = comp == GPU_TAB - when (driverPage) { - "rt" -> { - DriverPage( - title = stringResource(R.string.comp_runtime_driver), hint = stringResource(R.string.comp_runtime_driver_hint), - rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads, - status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false, - onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh, - onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null }, - ) - return - } - "panel" -> { - DriverPage( - title = stringResource(R.string.comp_display_driver), hint = stringResource(R.string.comp_display_driver_hint), - rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads, - status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_adrenotools), - canRestore = gpu.canRestoreBundled, - onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh, - onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled, - onBack = { driverPage = null }, - ) - return - } - } + val density = LocalDensity.current + val inputMode = LocalInputModeManager.current + val layer = if (comp in LAYERS) comp else GPU_TAB + BackHandler(onBack = onBack) val views = snapshot?.protons ?: emptyList() val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull() - val label = ComponentsManager.LABEL[comp] ?: comp - val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else stringResource(R.string.comp_never) - val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else stringResource(R.string.comp_nightlies_checked, checked) - Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - // ---- title, and the one thing that goes online ----------------------------------------- - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - Text( - stringResource(R.string.comp_title), fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, - maxLines = 1, - ) - // The status gives way to the title, not the other way round. - if (!narrow) Text( - nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f), - ) else Spacer(Modifier.weight(1f)) - if (!gpuTab) ToolIcon(Icons.Outlined.Info, stringResource(R.string.comp_about)) { about = true } - if (gpuTab) ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.comp_check_drivers), busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh) - else ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.comp_check_nightlies), busy = checking, enabled = !checking, onClick = onRefresh) - } - if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + // Where things sit, root px: each layer card, each row (for a swap's dot), the page itself. + val cardsAt = remember { mutableStateMapOf() } + val rowsAt = remember { HashMap() } + var stackAt by remember { mutableStateOf(Offset.Zero) } + var pageAt by remember { mutableStateOf(Offset.Zero) } + // A layer card pops and rings as a swap's dot lands on it, and rings as a hop arrives on it. + val landed = remember { mutableStateMapOf() } + val rings = remember { mutableStateMapOf() } + fun land(l: String) { landed[l] = (landed[l] ?: 0) + 1; rings[l] = (rings[l] ?: 0) + 1 } + val arrivedSeen = remember { intArrayOf(Hops.arrived) } + LaunchedEffect(Hops.arrived) { + if (Hops.arrived == arrivedSeen[0]) return@LaunchedEffect + arrivedSeen[0] = Hops.arrived + // Once the page has flooded in from the rail. + delay(Motion.ms(700).toLong()) + rings[layer] = (rings[layer] ?: 0) + 1 + } + /** The row at [key] squashes into a dot and flies home to [to]'s card, which pops on landing. */ + fun flyHome(key: String, to: String) { + val from = rowsAt[key] ?: return land(to) + Flights.fly(Flight(from, to = { cardsAt[to]?.center }, onLand = { land(to) })) + } - // ---- which Proton, which component ------------------------------------------------------ - val comps = ComponentsManager.COMPONENTS - androidx.compose.foundation.layout.FlowRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + // A confirm stepping out of the row that asked: right of a left-column row, left of a right one. + fun ask(key: String, right: Boolean, title: String, note: String, verb: String, danger: Boolean, face: String = "", then: () -> Unit) { + val at = rowsAt[key] ?: return then() + val cancel = ctx.getString(R.string.common_cancel) + Steps.ask(StepAsk( + anchor = at, side = if (right) StepSide.Left else StepSide.Right, + accent = if (danger) colors.error else pal.signal, pillCorner = 10.dp, items = 3, maxWidth = 340.dp, + // The row's own name rides on top, as the pull's handle. + handle = { + Box(Modifier.fillMaxSize().padding(start = 46.dp, end = 14.dp), contentAlignment = Alignment.CenterStart) { + Text(face, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else pal.signal, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + }, ) { - if (view != null && !gpuTab) Box { - ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu } - AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = stringResource(R.string.comp_proton), - note = stringResource(R.string.comp_proton_note)) { first -> - views.forEachIndexed { i, v -> - val swaps = v.components.values.count { it.activeFile != null } - MenuItem( - v.proton.name, checked = v.proton.id == view.proton.id, - detail = (if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version)) - .let { - when (v.sync?.state) { - "pack" -> stringResource(R.string.comp_esync_pack, it) - "wanted" -> stringResource(R.string.comp_esync_wanted, it) - "builtin" -> stringResource(R.string.comp_esync_builtin, it) - else -> it - } - } - .let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it }, - focusRequester = if (i == 0) first else null, - ) { onProton(v.proton.id); protonMenu = false } - } - } + StepTitle(title) + if (note.isNotEmpty()) Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { + StepChoice(cancel, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(verb, danger = danger, enabled = open) { Steps.fold(); then() } } - val tabs = listOf(GPU_TAB) + comps - TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: stringResource(R.string.comp_gpu_drivers) }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) }) - } + }) + } - // ---- what is in use ----------------------------------------------------------------------- - if (view != null && !gpuTab) { - val st = view.components.getValue(comp) - val reappliedAt = if (view.reappliedAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else null - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) - .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp), - ) { - Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape)) - Text( - (if (st.queued != null) stringResource(R.string.comp_next_queued, st.queued) else stringResource(R.string.comp_in_use, label, st.inUse)) - .let { if (reappliedAt != null) stringResource(R.string.comp_reapplied, it, reappliedAt) else it }, - fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - ) - if (st.queued != null) FocusText(stringResource(R.string.common_cancel), pal.signal, onClick = onCancelQueued) - else if (!narrow) Text( - if (view.inUseByGame) stringResource(R.string.comp_waits) else stringResource(R.string.comp_applies_next_game), - fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, + // The runtime and display drivers on their own pages, flooding out of the link that opens them. + var driverPage by remember { mutableStateOf(null) } + var driverShown by remember { mutableStateOf(null) } + var driverLeaving by remember { mutableStateOf(false) } + var driverFrom by remember { mutableStateOf(null) } + LaunchedEffect(driverPage) { + val d = driverPage + if (d != null) { driverLeaving = false; driverShown = d } + else if (driverShown != null) { + if (driverFrom != null) { driverLeaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) } + driverShown = null; driverLeaving = false; driverFrom = null + } + } + // Narrow: a layer's detail floods out of its card, and B draws it back in. + var narrowOpen by rememberSaveable { mutableStateOf(null) } + var narrowShown by remember { mutableStateOf(null) } + var narrowLeaving by remember { mutableStateOf(false) } + LaunchedEffect(narrowOpen) { + val d = narrowOpen + if (d != null) { narrowLeaving = false; narrowShown = d } + else if (narrowShown != null) { + narrowLeaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) + narrowShown = null; narrowLeaving = false + } + } + if (!narrow && narrowOpen != null) narrowOpen = null + BackHandler(enabled = driverPage != null || narrowOpen != null) { + if (driverPage != null) driverPage = null else narrowOpen = null + } + // Leaving the page: nothing stays stepped out of a row that is no longer on screen. + androidx.compose.runtime.DisposableEffect(Unit) { onDispose { Steps.drop() } } + + val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else stringResource(R.string.comp_never) + val firstRow = remember { FocusRequester() } + val layerFocus = remember { LAYERS.associateWith { FocusRequester() } } + // Arrived (from the rail, a hop, a game's card): a pad starts on the chosen layer. + LaunchedEffect(requestInitialFocus) { + if (!requestInitialFocus || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect + delay(Motion.ms(200).toLong()) + focusSoon { layerFocus.getValue(layer) } + } + var travel by remember { mutableIntStateOf(1) } + // LB / RB (taken by the activity before the page sees them): a pad anywhere on the page goes to + // the new layer's card, so one on a row - which leaves with its layer - is never left with nothing. + var pageFocused by remember { mutableStateOf(false) } + val seenLayer = remember { arrayOf(layer) } + LaunchedEffect(layer) { + if (seenLayer[0] == layer) return@LaunchedEffect + seenLayer[0] = layer + if (!pageFocused || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect + if (narrowOpen != null) runCatching { firstRow.requestFocus() } + else focusSoon { layerFocus.getValue(layer) } + } + val pick: (String) -> Unit = { l -> + if (l != layer) { travel = if (LAYERS.indexOf(l) > LAYERS.indexOf(layer)) 1 else -1; onComp(l) } + } + + // ---- one layer's detail, a slot at a time ------------------------------------------------------ + val compState = { l: String -> view?.components?.get(l) } + val bar: @Composable (String) -> Unit = { l -> + when (l) { + GPU_TAB -> { + val active = gpu.activePair + InUseBar( + queued = gpu.busy != null, + sentence = (active?.let { "${it.name} ${it.version}" } ?: gpu.activeBundle ?: stringResource(R.string.gpu_builtin)) + .let { if (gpu.auto) stringResource(R.string.gpu_bar_auto, it) else it }, + note = if (gpu.busy != null) (if (gpu.percent >= 0) "${gpu.percent}%" else "…") + else gpu.autoStatus.ifEmpty { if (active != null || gpu.activeBundle != null) "" else gpu.pairs.firstOrNull { it.recommended }?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint) }, ) } - } - - // ---- the lists ------------------------------------------------------------------------------ - when { - gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { - GpuDriversPanel(gpu, gpuActions) { driverPage = it } - Row(modifier = Modifier.padding(top = 14.dp)) { - SmallButton(stringResource(R.string.comp_import_zip), onClick = gpuActions.onImportZip) - } - } - snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { - CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(12.dp)) - Text(stringResource(R.string.comp_reading), fontSize = 14.sp, color = colors.onSurfaceVariant) - } - view == null -> Text( - stringResource(R.string.comp_no_proton), - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp), + PROTON_TAB -> InUseBar( + queued = false, + sentence = view?.let { stringResource(R.string.comp_proton_bar, it.proton.name) } ?: stringResource(R.string.comp_no_proton), + note = if (view?.inUseByGame == true) stringResource(R.string.comp_waits) else "", ) else -> { - val p = view.proton - val st = view.components.getValue(comp) - val build = ComponentsManager.safeName(p.version) - val originals = view.originals.filter { it.comp == comp } - val stored = snapshot.packages.filter { it.comp == comp } - val activeIsOriginal = st.activeFile == null - val rows = originals.map { o -> - val isCurrent = o.protonVersion == build - InstalledItem( - stringResource(R.string.comp_original, o.protonVersion), - o.label.substringAfterLast(" · ", "").ifEmpty { o.label }.let { if (isCurrent) stringResource(R.string.comp_this_build, it) else stringResource(R.string.comp_earlier_build, it) }, - "ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion, + val st = compState(l) + val label = ComponentsManager.LABEL[l] ?: l + if (st == null) InUseBar(false, stringResource(R.string.comp_no_proton), "") + else { + val reappliedAt = view?.reappliedAt?.takeIf { it > 0 }?.let { DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(it * 1000)) } + InUseBar( + queued = st.queued != null, + sentence = (if (st.queued != null) stringResource(R.string.comp_next_queued, st.queued) else stringResource(R.string.comp_in_use, label, st.inUse)) + .let { if (reappliedAt != null) stringResource(R.string.comp_reapplied, it, reappliedAt) else it }, + note = if (view?.inUseByGame == true) stringResource(R.string.comp_waits) else stringResource(R.string.comp_applies_next_game), + onCancel = if (st.queued != null) onCancelQueued else null, ) - } + stored.map { s -> - InstalledItem(s.version, stringResource(R.string.common_size_mb, s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null) - } - val storedNames = snapshot.packages.map { it.file }.toSet() - val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames } - val first = remember { FocusRequester() } - LaunchedEffect(p.id, comp, requestInitialFocus) { - if (requestInitialFocus) runCatching { first.requestFocus() } } + } + } + } - val installed: @Composable () -> Unit = { - SettingsGroup(stringResource(R.string.comp_installed_count, rows.size)) { - if (rows.isEmpty()) Text( - stringResource(R.string.comp_nothing_stored, label), - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - ) - rows.forEachIndexed { i, item -> - InstalledLine( - item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier, - onSelect = { - if (item.selected) return@InstalledLine - val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long) - if (item.file != null) ask(ctx.getString(R.string.comp_swap_title, label), ctx.getString(R.string.comp_swap_body, item.name, p.name, waits), R.string.comp_swap) { onSwap(item.file) } - else ask(ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_body, item.protonVersion, p.name, waits), R.string.common_restore) { onRestore(item.protonVersion!!) } - }, - onDelete = { - if (item.file != null) ask(ctx.getString(R.string.common_delete_named, item.name) + "?", ctx.getString(R.string.comp_delete_package_body), R.string.common_delete) { onDeletePackage(item.file) } - else ask(ctx.getString(R.string.comp_delete_original_title), ctx.getString(R.string.comp_delete_original_body, item.protonVersion, p.name), R.string.common_delete) { onDeleteOriginal(item.protonVersion!!) } - }, - ) + // Installed and Available, as (title, rows) for a layer; [right] says which column it is in. + var showAllPairs by rememberSaveable { mutableStateOf(false) } + val installedTitle: @Composable (String) -> String = { l -> + when (l) { + GPU_TAB -> stringResource(R.string.comp_installed_count, 1 + gpu.pairs.count { it.installed && (it.suits || showAllPairs) }) + PROTON_TAB -> stringResource(R.string.comp_installed_count, views.size) + else -> stringResource(R.string.comp_installed_count, installedFor(l, view, snapshot).size) + } + } + val availableTitle: @Composable (String) -> String = { l -> + when (l) { + GPU_TAB -> stringResource(R.string.comp_available_count, gpu.pairs.count { !it.installed && it.complete && (it.suits || showAllPairs) }) + PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null }) + else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size) + } + } + val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right -> + when (l) { + GPU_TAB -> { + val autoKey = "gpu:auto" + ChoiceLine( + stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_keeps), gpu.auto, removable = false, + modifier = Modifier.focusRequester(firstRow).trackRow(autoKey, rowsAt), + onSelect = { + if (gpu.auto) return@ChoiceLine + ask(autoKey, right, ctx.getString(R.string.gpu_use_title, ctx.getString(R.string.common_auto)), ctx.getString(R.string.gpu_auto_hint), ctx.getString(R.string.common_use), false, ctx.getString(R.string.common_auto)) { + gpuActions.onAuto(true); flyHome(autoKey, GPU_TAB) + } + }, + ) + for (p in gpu.pairs.filter { it.installed && (it.suits || showAllPairs) }) { + val key = "gpu:${p.key}" + ChoiceLine( + p.name, "${p.version} · ${p.detail}", !gpu.auto && p.active, removable = false, + badge = if (p.recommended) stringResource(R.string.gpu_recommended_caps) else null, + progress = if (gpu.busy == p.key) gpu.percent else null, + modifier = Modifier.trackRow(key, rowsAt), + onSelect = { + if (!gpu.auto && p.active || gpu.busy != null) return@ChoiceLine + ask(key, right, ctx.getString(R.string.gpu_use_title, p.name), "${p.version} · ${p.detail}", ctx.getString(R.string.common_use), false, p.name) { + gpuActions.onAuto(false); gpuActions.onPair(p.key); flyHome(key, GPU_TAB) + } + }, + ) + } + } + PROTON_TAB -> { + if (views.isEmpty()) EmptyLine(stringResource(if (snapshot == null) R.string.comp_reading else R.string.comp_no_proton)) + views.forEachIndexed { i, v -> + val key = "proton:${v.proton.id}" + val swaps = v.components.values.count { it.activeFile != null } + val extra = protons.rows.firstOrNull { it.installed == v.proton.dir.name } + val removable = extra != null && protons.busyId == null && protons.runtimeReady && !protons.sessionRunning + ChoiceLine( + v.proton.name, + (if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version)) + .let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it }, + v.proton.id == view?.proton?.id, removable = removable, + modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt), + onSelect = { if (v.proton.id != view?.proton?.id) { onProton(v.proton.id); flyHome(key, PROTON_TAB) } }, + onDelete = { + if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), "", ctx.getString(R.string.store_remove), true, v.proton.name) { + protonActions.onRemove(extra.id) + } + }, + ) + } + } + else -> { + val label = ComponentsManager.LABEL[l] ?: l + val rows = installedFor(l, view, snapshot) + if (rows.isEmpty()) EmptyLine(stringResource(R.string.comp_nothing_stored, label)) + val p = view?.proton + rows.forEachIndexed { i, item -> + val key = "row:$l:${item.file ?: item.protonVersion}" + ChoiceLine( + item.name, item.detail, item.selected, item.removable, tag = item.tag, + modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt), + onSelect = { + if (item.selected || p == null) return@ChoiceLine + val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long) + if (item.file != null) ask(key, right, ctx.getString(R.string.comp_swap_title, label), ctx.getString(R.string.comp_swap_body, item.name, p.name, waits), ctx.getString(R.string.comp_swap), false, item.name) { + onSwap(item.file); flyHome(key, l) + } + else ask(key, right, ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_body, item.protonVersion, p.name, waits), ctx.getString(R.string.common_restore), false, item.name) { + onRestore(item.protonVersion!!); flyHome(key, l) + } + }, + onDelete = { + if (p == null) return@ChoiceLine + if (item.file != null) ask(key, right, ctx.getString(R.string.common_delete_named, item.name) + "?", ctx.getString(R.string.comp_delete_package_body), ctx.getString(R.string.common_delete), true, item.name) { onDeletePackage(item.file) } + else ask(key, right, ctx.getString(R.string.comp_delete_original_title), ctx.getString(R.string.comp_delete_original_body, item.protonVersion, p.name), ctx.getString(R.string.common_delete), true, item.name) { onDeleteOriginal(item.protonVersion!!) } + }, + ) + } + } + } + } + val availableRows: @Composable ColumnScope.(String) -> Unit = { l -> + when (l) { + GPU_TAB -> { + val shown = gpu.pairs.filter { !it.installed && it.complete && (it.suits || showAllPairs) } + if (shown.isEmpty()) EmptyLine(stringResource(if (gpu.checking) R.string.gpu_checking else R.string.gpu_none_listed)) + for (p in shown) GetLine( + p.name, "${p.version} · ${p.detail}", progress = if (gpu.busy == p.key) gpu.percent else null, enabled = gpu.busy == null, + badge = if (p.recommended) stringResource(R.string.gpu_recommended_caps) else null, + ) { gpuActions.onAuto(false); gpuActions.onPair(p.key) } + if (gpu.pairs.any { !it.suits }) Box(Modifier.padding(horizontal = 6.dp, vertical = 4.dp)) { + FocusText(stringResource(if (showAllPairs) R.string.gpu_hide_other else R.string.gpu_show_other), pal.signal) { showAllPairs = !showAllPairs } + } + } + PROTON_TAB -> { + val rows = protons.rows.filter { it.installed == null } + if (rows.isEmpty()) EmptyLine(stringResource(R.string.comp_nothing_new)) + for (row in rows) GetLine( + row.name, if (row.queued) stringResource(R.string.proton_queued) else stringResource(R.string.proton_not_installed), + progress = if (protons.busyId == row.id) protons.percent else null, + stage = if (protons.busyId == row.id) protons.stage else null, + enabled = protons.busyId == null && protons.runtimeReady && !protons.sessionRunning, + onCancel = if (row.queued && protons.busyId == null && !protons.sessionRunning) ({ protonActions.onCancel(row.id) }) else null, + ) { protonActions.onInstall(row.id) } + } + else -> { + val available = availableFor(l, catalog, snapshot) + if (available.isEmpty()) EmptyLine(if (catalogAt == 0L) stringResource(R.string.comp_refresh_to_list) else stringResource(R.string.comp_nothing_new)) + for (d in available) GetLine( + d.file.removeSuffix(".wcp"), "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0), + progress = downloads[d.file], enabled = busy == null, + ) { onDownload(d) } + } + } + } + val actions: @Composable (String) -> Unit = { l -> + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { + when (l) { + GPU_TAB -> { + SmallButton(stringResource(R.string.comp_import_zip), onClick = gpuActions.onImportZip) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + FocusText(stringResource(R.string.gpu_separately) + " ›", pal.signal) { + val from = at + val title = ctx.getString(R.string.gpu_separately) + if (from == null) { driverPage = "rt"; return@FocusText } + Steps.ask(StepAsk(anchor = from, side = StepSide.Right, accent = pal.signal, pillCorner = 10.dp, items = 3, handle = {}) { + StepTitle(title) + StepList { + StepChoice(ctx.getString(R.string.comp_runtime_driver), enabled = open, modifier = Modifier.fillMaxWidth().stepItem(1).focusRequester(first)) { + Steps.fold(); driverFrom = Origin(from.translate(-pageAt), 10f * density.density); driverPage = "rt" + } + StepChoice(ctx.getString(R.string.comp_display_driver), enabled = open, modifier = Modifier.fillMaxWidth().stepItem(2)) { + Steps.fold(); driverFrom = Origin(from.translate(-pageAt), 10f * density.density); driverPage = "panel" + } + } + }) } } } - val nightly: @Composable () -> Unit = { - SettingsGroup(stringResource(R.string.comp_nightlies_count, available.size)) { - if (available.isEmpty()) Text( - if (catalogAt == 0L) stringResource(R.string.comp_refresh_to_list) else stringResource(R.string.comp_nothing_new), - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - ) - available.forEach { d -> AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) } } - } - } - Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { - if (narrow) { installed(); nightly() } - else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) { - Column(Modifier.weight(1f)) { installed() } - Column(Modifier.weight(1f)) { nightly() } - } - Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) { - SmallButton(stringResource(R.string.comp_import_wcp), onClick = onImport) - if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText(stringResource(R.string.comp_restore_original), pal.signal) { - ask(ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_original_body, p.name, label), R.string.common_restore) { onRestore(build) } + PROTON_TAB -> {} + else -> { + SmallButton(stringResource(R.string.comp_import_wcp), onClick = onImport) + val st = compState(l) + val p = view?.proton + val build = p?.let { ComponentsManager.safeName(it.version) } + if (st != null && p != null && st.activeFile != null && view.originals.any { it.comp == l && it.protonVersion == build }) { + val label = ComponentsManager.LABEL[l] ?: l + Box(Modifier.trackRow("restore:$l", rowsAt)) { + FocusText(stringResource(R.string.comp_restore_original), pal.signal) { + ask("restore:$l", false, ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_original_body, p.name, label), ctx.getString(R.string.common_restore), false, ctx.getString(R.string.comp_restore_original)) { + onRestore(build!!); flyHome("restore:$l", l) + } + } } } } @@ -318,34 +482,298 @@ fun ComponentsPage( } } - if (about) AlertDialog( - onDismissRequest = { about = false }, - title = { Text(stringResource(R.string.comp_title)) }, - text = { - Text( - stringResource(R.string.comp_about_text), - fontSize = 14.sp, - ) - }, - confirmButton = { FocusText(stringResource(R.string.common_ok), pal.signal) { about = false } }, - ) - confirm?.let { (body, action) -> - AlertDialog( - onDismissRequest = { confirm = null }, - title = { Text(confirmTitle) }, - text = { Text(body, fontSize = 14.sp) }, - // Opens on Cancel, so a stray A press on a controller never swaps or deletes anything. - confirmButton = { - val del = confirmVerb == R.string.common_delete - FocusText(stringResource(confirmVerb), if (del) colors.error else pal.signal) { confirm = null; action() } - }, - dismissButton = { - val cancelFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } } - FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null } + // ---- the frame ------------------------------------------------------------------------------ + val detail: @Composable (String, Boolean) -> Unit = { l, narrowPage -> + androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) { + // Two columns while each gets room for a row's name and its Get; one under the other below that. + val single = narrowPage || maxWidth < 600.dp + Column(Modifier.fillMaxSize()) { + // The in-use bar keeps its place; only its words change. + AnimatedContent(l, transitionSpec = { fadeIn(Motion.tw(240)) togetherWith fadeOut(Motion.tw(160)) }, label = "inUse") { k -> bar(k) } + Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 16.dp)) { + val slide: @Composable (@Composable (String) -> Unit) -> Unit = { body -> + AnimatedContent( + l, + transitionSpec = { + val d = travel + val shift = with(density) { 16.dp.roundToPx() } + (slideInVertically(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240))) + .togetherWith(slideOutVertically(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240))) + }, + label = "layerRows", + ) { k -> body(k) } + } + if (single) { + slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } + Spacer(Modifier.height(12.dp)) + slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } + } else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } } + Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } } + } + Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } } + if (l == PROTON_TAB) { + Spacer(Modifier.height(10.dp)) + if (!protons.runtimeReady) EmptyLine(stringResource(R.string.content_needs_runtime)) + if (protons.sessionRunning) EmptyLine(stringResource(R.string.proton_stop_first)) + SettingsGroup(stringResource(R.string.comp_for_every_proton)) { protonOptions() } + Spacer(Modifier.height(12.dp)) + Note(stringResource(R.string.comp_about_text)) + } + } + } + } + } + + Box(Modifier.fillMaxSize().onGloballyPositioned { pageAt = it.positionInRoot() }.onFocusChanged { pageFocused = it.hasFocus }) { + Column( + Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp) + .bumpers(onPrevious = { pick(layerStep(layer, -1)) }, onNext = { pick(layerStep(layer, 1)) }), + ) { + // ---- header: the title, the device, when the lists were checked and the one refresh ---- + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.comp_title), fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + if (!narrow) DeviceChip(gpu) + Spacer(Modifier.weight(1f)) + HopBackChip(onBack) + if (!narrow) Text( + if (busy != null) "$busy…" else stringResource(R.string.comp_checked, checked), + fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + val refreshing = checking || gpu.checking + ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.comp_check_all), busy = refreshing, enabled = !refreshing && gpu.busy == null) { + onRefresh(); gpuActions.onRefresh() + } + } + if (narrow) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 6.dp)) { DeviceChip(gpu) } + Spacer(Modifier.height(14.dp)) + Row(Modifier.weight(1f).fillMaxWidth()) { + // ---- the stack ---- + Box( + (if (narrow) Modifier.fillMaxWidth() else Modifier.width(250.dp)).fillMaxHeight() + .onGloballyPositioned { stackAt = it.positionInRoot() }, + ) { + val tint = rememberUpdatedState(pal.signal) + val local = cardsAt[layer]?.translate(-stackAt) + ChipGlide(LAYERS.indexOf(layer), local, tint, GlideAxis.Vertical, corner = 12.dp) + Stack( + layer, view, gpu, focus = layerFocus, landed = landed, rings = rings, + onPlaced = { l, r -> cardsAt[l] = r }, + onFocus = { l -> pick(l) }, + onOpen = { l -> + pick(l) + if (narrow) narrowOpen = l + else runCatching { firstRow.requestFocus() } + }, + ) + } + if (!narrow) { + Spacer(Modifier.width(16.dp)) + Box(Modifier.weight(1f).fillMaxHeight()) { detail(layer, false) } + } + } + } + // Narrow: the layer's detail over the stack, out of its card. + narrowShown?.let { l -> + val from = cardsAt[l]?.let { Origin(it.translate(-pageAt), 12f * density.density) } + val body: @Composable () -> Unit = { + Column( + Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } } + .padding(horizontal = 16.dp, vertical = 12.dp).focusGroup(), + ) { + BackLink(LayerTitle(l), compact = true) { narrowOpen = null } + Spacer(Modifier.height(8.dp)) + detail(layer, true) + } + } + if (from == null) body() else PageFlood(from, narrowLeaving) { body() } + } + // The runtime or display driver on its own page. + driverShown?.let { d -> + val body: @Composable () -> Unit = { + Box(Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } }) { + if (d == "rt") DriverPage( + title = stringResource(R.string.comp_runtime_driver), hint = stringResource(R.string.comp_runtime_driver_hint), + rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads, + status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false, + onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh, + onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null }, + ) + else DriverPage( + title = stringResource(R.string.comp_display_driver), hint = stringResource(R.string.comp_display_driver_hint), + rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads, + status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_adrenotools), + canRestore = gpu.canRestoreBundled, + onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh, + onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled, + onBack = { driverPage = null }, + ) + } + } + val from = driverFrom + if (from == null) body() else PageFlood(from, driverLeaving) { body() } + } + } +} + +/** The Installed rows of one component layer for the Proton the page shows. */ +@Composable +private fun installedFor(l: String, view: ComponentsManager.ProtonView?, snapshot: Snapshot?): List { + view ?: return emptyList() + val st = view.components[l] ?: return emptyList() + val build = ComponentsManager.safeName(view.proton.version) + val activeIsOriginal = st.activeFile == null + return view.originals.filter { it.comp == l }.map { o -> + val isCurrent = o.protonVersion == build + InstalledItem( + stringResource(R.string.comp_original, o.protonVersion), + o.label.substringAfterLast(" · ", "").ifEmpty { o.label }.let { if (isCurrent) stringResource(R.string.comp_this_build, it) else stringResource(R.string.comp_earlier_build, it) }, + "ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion, + ) + } + (snapshot?.packages ?: emptyList()).filter { it.comp == l }.map { s -> + InstalledItem(s.version, stringResource(R.string.common_size_mb, s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null) + } +} + +/** The Nightlies of one component layer that are not stored yet. */ +private fun availableFor(l: String, catalog: List, snapshot: Snapshot?): List { + val stored = (snapshot?.packages ?: emptyList()).map { it.file }.toSet() + return catalog.filter { it.comp == l && ComponentsManager.safeName(it.file) !in stored } +} + +@Composable +private fun LayerTitle(l: String): String = when (l) { + GPU_TAB -> stringResource(R.string.comp_gpu_drivers) + PROTON_TAB -> stringResource(R.string.comp_proton) + else -> ComponentsManager.LABEL[l] ?: l +} + +/** Records where a row sits, for the confirm that steps out of it and the dot that flies from it. */ +private fun Modifier.trackRow(key: String, into: HashMap): Modifier = onGloballyPositioned { into[key] = it.boundsInRoot() } + +/** + * The stack: a heading over the Proton and the three layers that belong to it (indented, on a + * line), then a heading over the GPU drivers that every Proton shares. Each card is a layer's name + * and what it is set to; a dot marks a layer swapped away from its original. + */ +@Composable +private fun Stack( + layer: String, view: ComponentsManager.ProtonView?, gpu: GpuDriversState, + focus: Map, landed: Map, rings: Map, + onPlaced: (String, Rect) -> Unit, onFocus: (String) -> Unit, onOpen: (String) -> Unit, +) { + val pal = LocalPalette.current + val original = stringResource(R.string.comp_tag_original) + val auto = stringResource(R.string.common_auto) + val card: @Composable (String) -> Unit = { l -> + val st = view?.components?.get(l) + LayerCard( + title = LayerTitle(l), + value = when (l) { + GPU_TAB -> if (gpu.auto) auto else gpu.activePair?.name ?: gpu.activeBundle ?: auto + PROTON_TAB -> (view?.proton?.name ?: "") + " ▾" + else -> st?.let { if (it.activeFile == null) original else it.inUse } ?: "" }, + swapped = l in ComponentsManager.COMPONENTS && st?.activeFile != null, + selected = l == layer, + landed = landed[l] ?: 0, ring = rings[l] ?: 0, + modifier = Modifier.focusRequester(focus.getValue(l)).onGloballyPositioned { onPlaced(l, it.boundsInRoot()) }, + onFocus = { onFocus(l) }, onClick = { onOpen(l) }, ) } + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(3.dp)) { + StackHeading(stringResource(R.string.comp_this_proton)) + card(PROTON_TAB) + Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + Box(Modifier.padding(start = 8.dp, top = 4.dp, bottom = 4.dp).width(2.dp).fillMaxHeight().background(pal.line2)) + Column(Modifier.weight(1f).padding(start = 6.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + card("dxvk"); card("vkd3d"); card("fex") + } + } + Spacer(Modifier.height(6.dp)) + StackHeading(stringResource(R.string.comp_every_proton)) + card(GPU_TAB) + } +} + +@Composable +private fun StackHeading(text: String) { + Text( + text.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.4.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 6.dp, bottom = 2.dp), + ) +} + +/** One layer: 40dp to see, 44 to touch; its name, what it is set to, and a dot when it is swapped. */ +@Composable +private fun LayerCard( + title: String, value: String, swapped: Boolean, selected: Boolean, landed: Int, ring: Int, + modifier: Modifier, onFocus: () -> Unit, onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Box(Modifier.fillMaxWidth().heightIn(min = 44.dp), contentAlignment = Alignment.Center) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxWidth().height(40.dp) + .landingPop(landed).pulseRing(ring, pal.signal) + .clip(LAYER_SHAPE) + .background(if (selected) Color.Transparent else colors.surface) + .glideBorder(hot, LAYER_SHAPE, pal.signal, if (selected) Color.Transparent else pal.line) + .onFocusChanged { if (it.isFocused) onFocus() } + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 12.dp), + ) { + if (swapped) Box(Modifier.size(7.dp).clip(CircleShape).background(pal.signal)) + Text(title, fontSize = 13.5.sp, fontWeight = FontWeight.Bold, color = if (selected || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1) + Spacer(Modifier.weight(1f)) + // The value ticks over as a swap lands. + AnimatedContent( + value, + transitionSpec = { (slideInVertically(Motion.tw(260)) { it / 2 } + fadeIn(Motion.tw(260))) togetherWith (slideOutVertically(Motion.tw(160)) { -it / 2 } + fadeOut(Motion.tw(160))) }, + label = "layerValue", + ) { v -> Text(v, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } +} + +/** What is in use for the layer: a dot (green, or amber while a change waits), a sentence, and a note or Cancel. */ +@Composable +private fun InUseBar(queued: Boolean, sentence: String, note: String, onCancel: (() -> Unit)? = null) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) + .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Box(Modifier.size(8.dp).background(if (queued) QUEUED else pal.good, CircleShape)) + Text(sentence, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (onCancel != null) FocusText(stringResource(R.string.common_cancel), pal.signal, onClick = onCancel) + else if (note.isNotEmpty() && !LocalNarrowPane.current) Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** A titled column of rows. */ +@Composable +private fun RowGroup(title: String, content: @Composable ColumnScope.() -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Column(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp)) { + Text(title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, maxLines = 1) + Box(Modifier.weight(1f).height(1.dp).background(pal.line)) + } + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(colors.surface).border(1.dp, pal.line, RoundedCornerShape(14.dp)), content = content) + } +} + +@Composable +private fun EmptyLine(text: String) { + Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) } @Composable @@ -353,10 +781,10 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } - val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val hot = rememberHot(src) Box( contentAlignment = Alignment.Center, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)) + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(12.dp)) .background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) .glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) @@ -376,7 +804,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } - val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled + val hot = rememberHot(src) && enabled Box( contentAlignment = Alignment.Center, modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) @@ -415,60 +843,73 @@ private fun Tag(text: String) { ) } +/** + * One installed choice: a radio for the one in use, its name and detail, and a trash at the right + * of the ones that can go. [progress] shows a download for it in place of the tag. + */ @Composable -private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) { +private fun ChoiceLine( + name: String, detail: String, selected: Boolean, removable: Boolean, + modifier: Modifier = Modifier, tag: String? = null, badge: String? = null, progress: Int? = null, + onSelect: () -> Unit, onDelete: () -> Unit = {}, +) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } - val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val hot = rememberHot(src) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() - .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.07f) else Color.Transparent), + .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.07f) else Color.Transparent), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier) .glideBorder(hot, ROW_SHAPE, pal.signal) - .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onSelect) .controllerConfirm(onClick = onSelect) .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape)) { - if (item.selected) Box(Modifier.size(9.dp).background(pal.signal, CircleShape)) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).border(2.dp, if (selected) pal.signal else colors.onSurfaceVariant, CircleShape)) { + if (selected) Box(Modifier.size(9.dp).background(pal.signal, CircleShape)) } Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( - item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false), + name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, + color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false), ) - Tag(item.tag) + if (tag != null) Tag(tag) } - Text(item.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp)) + DetailLine(detail, badge) } - if (item.selected) Tag("IN USE") + // The radio says which is in use; a download in flight shows where a tag would. + if (progress != null) ProgressBit(progress) } // The trash column is always there (empty when the row can't be deleted) so rows line up. - if (!item.removable) Spacer(Modifier.padding(end = 6.dp).size(40.dp)) + if (!removable) Spacer(Modifier.padding(end = 6.dp).size(44.dp)) else { val delSrc = remember { MutableInteractionSource() } - val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value + val delHot = rememberHot(delSrc) Box( contentAlignment = Alignment.Center, - modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE) + modifier = Modifier.padding(end = 6.dp).size(44.dp).clip(ROW_SHAPE) .background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent) .glideBorder(delHot, ROW_SHAPE, colors.error) .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) .controllerConfirm(onClick = onDelete), - ) { Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.common_delete_named, item.name), tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } + ) { Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.common_delete_named, name), tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } } } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } +/** One thing not installed yet: its name and detail, and Get - its download shown in the row once started. */ @Composable -private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDownload: () -> Unit) { +private fun GetLine( + name: String, detail: String, progress: Int?, enabled: Boolean, + badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, onGet: () -> Unit, +) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current Row( @@ -476,22 +917,105 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 14.dp, end = 10.dp, top = 8.dp, bottom = 8.dp), ) { Column(Modifier.weight(1f)) { - Text(d.file.removeSuffix(".wcp"), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0), fontSize = 13.sp, color = colors.onSurfaceVariant, - maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp), - ) + Text(name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + DetailLine(stage ?: detail, badge) } - if (progress == null) SmallButton(stringResource(R.string.common_download), enabled = enabled, accent = true, onClick = onDownload) - else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { - Text(if (progress < 0) "…" else "$progress%", fontSize = 13.sp, color = colors.onSurfaceVariant) - Spacer(Modifier.height(4.dp)) - if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth()) + if (progress != null) ProgressBit(progress) + else { + if (onCancel != null) FocusText(stringResource(R.string.proton_cancel_queued), pal.signal, onClick = onCancel) + SmallButton(stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet) } } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } -/** The Components page's tab for the GPU drivers, before the Proton components' own. */ -const val GPU_TAB = "gpu" +/** A row's second line, led by [badge] (Recommended) in green when there is one. */ +@Composable +private fun DetailLine(detail: String, badge: String?) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val text = androidx.compose.ui.text.buildAnnotatedString { + if (badge != null) { + pushStyle(androidx.compose.ui.text.SpanStyle(color = pal.good, fontWeight = FontWeight.SemiBold)) + append(badge.lowercase().replaceFirstChar { it.uppercase() }) + pop() + append(" · ") + } + append(detail) + } + Text(text, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp)) +} + +@Composable +private fun ProgressBit(progress: Int) { + val colors = MaterialTheme.colorScheme + Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { + Text(if (progress < 0) "…" else "$progress%", fontSize = 13.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth()) + } +} + +/** + * The GPU and how far it is supported, as a chip: a green dot and " · tested", amber when it + * is not. A steps the device's full details out of it, with the copy for a bug report. + */ +@Composable +private fun DeviceChip(s: GpuDriversState) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val tint = if (s.supported && !s.unsupported) pal.good else AttentionAmber + val label = stringResource( + when { + s.unsupported -> R.string.comp_device_unsupported + s.supported -> R.string.comp_device_tested + else -> R.string.comp_device_untested + }, s.gpuName, + ) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + var at by remember { mutableStateOf(null) } + val shape = RoundedCornerShape(99.dp) + val face: @Composable () -> Unit = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(tint)) + Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + val open: () -> Unit = { + at?.let { from -> + Steps.ask(StepAsk(anchor = from, side = StepSide.Right, accent = pal.signal, items = 2, maxWidth = 420.dp, handle = face) { + Box(Modifier.stepItem(0)) { DeviceDetails(first) } + }) + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.onGloballyPositioned { at = it.boundsInRoot() }.heightIn(min = 44.dp).paneItem("gpu-device").clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = open) + .controllerConfirm(onClick = open) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { face() } +} + +/** What the Proton layer installs: the Protons DroidDeck can download, and one being installed. */ +class ProtonsState( + val rows: List = emptyList(), + val busyId: String? = null, + val stage: String? = null, + val percent: Int = -1, + val runtimeReady: Boolean = true, + val sessionRunning: Boolean = false, +) + +class ProtonsActions( + val onInstall: (String) -> Unit = {}, + val onCancel: (String) -> Unit = {}, + val onRemove: (String) -> Unit = {}, +) + +/** A Proton DroidDeck can install, and the folder it is installed as (null when it is not). */ +class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt new file mode 100644 index 000000000..9d52bdf50 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt @@ -0,0 +1,455 @@ +package com.droiddeck.launcher.ui + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.DesktopWindows +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.zIndex +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.runtime.UserApps +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.store.UserAppsState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// The Desktop tab in the Steam tab's shape: a tilted wall of little windows behind the word and +// one big Open desktop, with Play's lip and lifted sheet under it (Display, Controls, Session and +// Files). Under the hero the page scrolls to two shelves - the apps that run in the desktop, and +// what can be added - and the hero folds into a slim header as it goes. + +/** The desktop's own chips: its session settings, then Files. */ +private val DESKTOP_TABS = modeSheetTabs(steam = false) + SheetTab.FILES + +@Composable +internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, onSelect: (String) -> Unit) { + val ctx = LocalContext.current + val density = LocalDensity.current + val narrow = LocalNarrowPane.current + val colors = MaterialTheme.colorScheme + LaunchedEffect(Unit) { UserAppsState.refresh(ctx) } + // Flathub's installed apps sit with the others on the dock. + LaunchedEffect(s.ready) { com.droiddeck.launcher.store.StoreState.refresh(ctx) } + val installed = s.emulators.filter { it.installed } + val apps = UserAppsState.items + var adding by rememberSaveable { mutableStateOf(false) } + val mode = SessionService.MODE_DESKTOP + val tabs = DESKTOP_TABS.map { it to sheetTabLabel(it) } + val open = if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop) + // Opened from a card: the page floods out of it, its picture on the tile. + fun openFrom(at: Rect?, art: Any?, page: String) { + at?.let { PageOrigin.mark(Origin(it, 16f * density.density, art = art)) } + onSelect(page) + } + + // The Steam tab's shape, the whole pane: the dock of apps along the top of the wall, the word + // and Open desktop at the foot, and nothing under the settings sheet but the hero itself. + LiftHero( + key = "desktop", tabs = tabs, + onOpen = a.onDesktopSettings, onPrimary = a.onDesktop, + modifier = modifier.clipToBounds(), + start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, + background = { + DesktopWall(installed.map { it.iconRes } + apps.mapNotNull { it.icon }) + }, + scrim = { lifted -> + WallFade() + // Over the fades, so the cards read at full strength; gone as the sheet lifts, so + // nothing but the wall is behind it. + Box(Modifier.graphicsLayer { val v = lifted(); alpha = 1f - v; translationY = -12.dp.toPx() * v }) { + DesktopDock(s, a, installed, apps, openFrom = ::openFrom) + } + }, + words = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // The word at the wordmark's size. + Text( + stringResource(R.string.rail_desktop), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp, + fontWeight = FontWeight.Black, color = colors.onBackground, + ) + BusyChip(s) + } + }, + button = { click -> + // Enabled without a runtime or the desktop: the session's loading screen installs them first. + PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click) + }, + beside = { + // Flathub, whose apps run in the desktop: its storefront floods out of this button. + HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") } + // Add your own app or game. + HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true } + }, + rows = { tab -> + if (tab == SheetTab.FILES) DesktopFiles(s, a) + else a.modeSheet(mode, tab, SheetExtras()) + }, + ) + if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) } +} + +/** + * The dock along the top of the Desktop hero: what runs in the desktop (emulators installed, apps + * added, Flathub's installed apps), then the emulators that can be added - one + * row of cards that scrolls sideways, over the wall. It sinks back with the wall as the sheet lifts. + */ +@Composable +private fun DesktopDock( + s: FrontEndState, a: FrontEndActions, installed: List, apps: List, + openFrom: (Rect?, Any?, String) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val narrow = LocalNarrowPane.current + val available = s.emulators.filter { !it.installed } + val flatpaks = com.droiddeck.launcher.store.StoreState.installed + val keys = installed.map { "emu:${it.id}" } + apps.map { "user:${it.key}" } + flatpaks.map { "flatpak:${it.id}" } + // Where the last of your apps sits, for a new emulator's dot to land after it. + var lastAt by remember { mutableStateOf(null) } + var dockAt by remember { mutableStateOf(null) } + val seen = remember { mutableSetOf() } + val mounted = remember { booleanArrayOf(false) } + LaunchedEffect(Unit) { mounted[0] = true } + val scroll = rememberScrollState() + // Nothing yet (the emulators' catalog still on its way): no heading over an empty row. + if (keys.isEmpty() && available.isEmpty() && UserAppsState.working == null && UserAppsState.lastError == null) return + Column(Modifier.fillMaxWidth().padding(top = if (narrow) 14.dp else 22.dp)) { + Text( + stringResource(R.string.user_apps_section).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp, + color = colors.onSurfaceVariant, modifier = Modifier.padding(start = if (narrow) 20.dp else 44.dp, bottom = 8.dp), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth().onGloballyPositioned { dockAt = it.boundsInRoot() } + .edgeFade(scroll).horizontalScroll(scroll).padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 2.dp, bottom = 4.dp), + ) { + keys.forEachIndexed { i, k -> + key(k) { + // A card that arrives after the dock is up (an emulator just installed) opens in. + val state = remember { MutableTransitionState(!mounted[0] || k in seen) }.apply { targetState = true } + LaunchedEffect(Unit) { seen += k } + AnimatedVisibility(state, enter = expandHorizontally(Motion.sp(0.75f)) + fadeIn(Motion.tw(300, 80))) { + Box(if (i == keys.lastIndex) Modifier.onGloballyPositioned { lastAt = it.boundsInRoot() } else Modifier) { + val e = installed.firstOrNull { "emu:${it.id}" == k } + val flatpak = flatpaks.firstOrNull { "flatpak:${it.id}" == k } + val app = apps.firstOrNull { "user:${it.key}" == k } + if (e != null) { + val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() } + DesktopCard( + k, e.name, + if (e.id == "retroarch") system else "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size), + art = e.iconRes, first = i == 0, + ) { at -> openFrom(at, e.iconRes, k) } + } else if (flatpak != null) DesktopCard( + k, flatpak.name, flatpak.summary ?: stringResource(R.string.flathub_name), + art = flatpak.icon ?: com.droiddeck.launcher.store.FlathubApi.iconUrl(flatpak.id), first = i == 0, + ) { at -> + // Its page on Flathub: open, update or remove it there. + com.droiddeck.launcher.store.StoreState.openRequest = flatpak.id + openFrom(at, flatpak.icon, "store") + } else if (app != null) DesktopCard(k, app.name, stringResource(app.kind.label()), art = app.icon, fallback = if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, first = i == 0) { at -> + openFrom(at, app.icon, k) + } + } + } + } + } + // Between yours and what can be added: a thin rule. + if (keys.isNotEmpty()) Box(Modifier.padding(horizontal = 4.dp).width(1.dp).height(120.dp).background(pal.line2)) + available.forEach { e -> + key(e.id) { + val pkg = Library.packageId(e.id)?.let { id -> s.packages?.firstOrNull { it.id == id } } + val busyHere = pkg != null && s.packageBusyId == pkg.id + val canGet = pkg != null && !s.busy && s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning + val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() } + DesktopCard( + "emu:${e.id}", e.name, if (busyHere) (s.packageStage ?: stringResource(R.string.store_installing)) else system, + art = e.iconRes, badge = if (busyHere) null else stringResource(R.string.comp_get), + progress = if (busyHere) s.packagePercent else null, first = keys.isEmpty() && e == available.first(), + ) { at -> + // Get installs it there and then, and a dot flies to where its card will be; + // when it cannot (no runtime, a session running), its page says why. + if (canGet && pkg != null && at != null) { + a.onInstallPackage(pkg.id) + val end = lastAt + val row = dockAt + Flights.fly(Flight(at, to = { + val r = row ?: return@Flight null + Offset((end?.right ?: r.left) + 70.dp.toPx(), r.center.y) + })) + } else openFrom(at, e.iconRes, "emu:${e.id}") + } + } + } + } + if (UserAppsState.working != null || UserAppsState.lastError != null) { + Box(Modifier.padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 6.dp)) { UserAppsProgressLine() } + } + } +} + +/** + * A square button the big one's height beside it on the hero: an icon in [tint] (the palette's + * signal when null). [onClick] gets its bounds, for a page to flood out of. + */ +@Composable +private fun HeroIconButton(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, tint: Color?, id: String, onClick: (Rect?) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.95f else if (hot) 1.04f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "heroIcon") + val shape = RoundedCornerShape(12.dp) + val accent = tint ?: pal.signal + var at by remember { mutableStateOf(null) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(56.dp).paneItem("btn:$id").onGloballyPositioned { at = it.boundsInRoot() } + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape).background(if (hot) accent.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.92f)) + .glideBorder(hot, shape, accent, pal.line2) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(at) } + .controllerConfirm { onClick(at) } + .semantics { contentDescription = description }, + ) { Icon(icon, null, tint = accent, modifier = Modifier.size(26.dp)) } +} + +/** + * The hero's wall: little windows tilted and drifting as Steam's capsules do, each a window with a + * bar along its foot and an emulator's or app's icon inside; blank windows when there are none. + */ +@Composable +private fun DesktopWall(icons: List) { + val pal = LocalPalette.current + val ground = remember(pal) { Brush.linearGradient(listOf(pal.surfaceVariant, pal.surface)) } + TiltedWall(icons.size, if (icons.isEmpty()) 75_000 else 50_000, capW = 132.dp, capH = 84.dp, gap = 14.dp, corner = 8.dp) { i -> + WallCapsule(ground, edge = pal.line2, icon = icons.getOrNull(i), bar = pal.background.copy(alpha = 0.7f)) + } +} + +/** + * One thing that runs in the desktop: its icon over a blurred, darkened copy of itself, a status on + * the art (Get, or how far an install is), and under it its name on up to two lines and what it + * is. [onClick] gets the art's bounds, for a page to flood out of or a dot to fly from. + */ +@Composable +private fun DesktopCard( + key: String, name: String, detail: String, art: Any?, + fallback: androidx.compose.ui.graphics.vector.ImageVector = Icons.Outlined.Apps, + first: Boolean = false, badge: String? = null, progress: Int? = null, accent: Boolean = false, tint: Color? = null, + onClick: (Rect?) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "deskCard") + val shape = RoundedCornerShape(16.dp) + var artAt by remember { mutableStateOf(null) } + val width = if (LocalNarrowPane.current) 116.dp else 132.dp + val artH = width * 0.62f + val ground = tint?.copy(alpha = 0.25f) ?: if (accent) pal.signal.copy(alpha = 0.12f) else colors.surfaceVariant + Column( + Modifier.width(width).paneItem("tile:desktop:$key").then(if (first) Modifier.firstTile() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(artAt) } + .controllerConfirm { onClick(artAt) } + .semantics { contentDescription = name }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { artAt = it.boundsInRoot() } + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(ground), + ) { + when (art) { + is Int -> { + Image( + painterResource(art), null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f } + .then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + Image(painterResource(art), null, modifier = Modifier.size(artH * 0.6f)) + } + null -> Icon(fallback, null, tint = tint ?: if (accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.42f)) + else -> { + AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f } + .then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.6f)) + } + } + Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) { + if (progress != null) Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) { + if (progress >= 0) CircularProgressIndicator(progress = { progress / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp)) + else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp)) + } + else if (badge != null) Text( + badge, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1, + modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(pal.signal).padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) { + Text(name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(detail, fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp)) + } + } +} + +/** + * The desktop sheet's Files chip: the file manager and the ROMs folder (both from Setup's old + * tools), how much the desktop takes, and Uninstall - its only home - which asks in red first. + */ +@Composable +private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current + SettingsGroup(stringResource(R.string.setup_tool_files)) { + SettingsRow(stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint)) { + SecondaryButton(stringResource(R.string.store_open), compact = true, onClick = a.onFiles) + } + SettingsRow(stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint)) { + SecondaryButton(stringResource(R.string.setup_choose), compact = true, onClick = a.onRoms) + } + } + if (s.desktopPresent) SettingsGroup(stringResource(R.string.content_linux_desktop)) { + val size by produceState(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } } + SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {} + SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) { + UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop) + } + } +} + +/** Uninstall the desktop: are you sure steps out of it in red, Keep first and focused. */ +@Composable +private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) { + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.common_uninstall) + val title = stringResource(R.string.desktop_uninstall_title) + val note = stringResource(R.string.desktop_remove_text) + val keep = stringResource(R.string.flathub_keep) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled, compact = true) { + 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 = 12.sp, fontWeight = 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 = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { + StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() } + } + }) + } + } +} + +/** The add or remove under way, or why the last one failed. */ +@Composable +private fun UserAppsProgressLine() { + val colors = MaterialTheme.colorScheme + val working = UserAppsState.working + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + if (working != null) { + val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting) + val percent = UserAppsState.percent + Text( + if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage), + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + if (percent >= 0) androidx.compose.material3.LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) + else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) + } else UserAppsState.lastError?.let { Note(it) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt index 64e1bfc60..992e337a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -56,9 +56,21 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil.compose.AsyncImage import java.io.File +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned // Cover art: posters, the art grid and its tiles. +/** Where each art tile sits, root px, by its key: a page opened from one floods out of it. */ +internal object TileBounds { + val at = HashMap() + + /** The page about to open came from tile [key] (its art or icon for the flood), if it is on screen. */ + fun mark(key: String, art: Any?, corner: Float) { + at[key]?.let { PageOrigin.mark(Origin(it, corner, art = art)) } + } +} + internal class Tile( val title: String, val sub: String?, val art: File?, val key: String, val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null, @@ -102,6 +114,7 @@ internal fun ArtGrid(tiles: List, wide: Boolean = false) { val src = remember { MutableInteractionSource() } val hot = rememberHot(src) val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier) + .onGloballyPositioned { TileBounds.at[t.key] = it.boundsInRoot() } Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 925b0a883..764967860 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -76,6 +76,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -124,15 +126,30 @@ internal fun Pane( val paneAt = remember { arrayOf(Offset.Zero) } val livePage by rememberUpdatedState(s.pageKey) // Picking another game changes the detail beside the list, not the whole page. - val target = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected + val railContent = if (selected.startsWith("app:")) "games" else selected + val target = if (page != null && s.pageKey != null) s.pageKey else railContent // The game the Games page shows. It must outlive the selection: while the page animates out, // drawing it with what is selected now (another rail item) turned it into a copy of that page. val gameShown = remember { arrayOf("games") } if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected + // What the pane showed before this change: the content a page opened over belongs to it. + val before = remember { arrayOf(target) } if (page != null && s.pageKey != null) { pages[s.pageKey] = page - if (s.pageKey !in origins) PageOrigin.take()?.let { origins[s.pageKey] = it.translate(-paneAt[0]) } + if (s.pageKey !in origins) PageOrigin.take()?.let { o -> + // A hop's page came out of its own rail item, and is that item's content. + origins[s.pageKey] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(railContent) else it } + } + } else if (target != before[0] && target !in origins) { + // A section's own page opened from a control in it (an emulator's card, a ROM's) floods + // out of that control and belongs to what was showing; a hop's comes out of its rail item. + PageOrigin.take()?.let { o -> origins[target] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(before[0]) else it } } } + before[0] = target + val targetNow by rememberUpdatedState(target) + // A page leaving through a hop's link is not coming back to its control. + val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary + LaunchedEffect(target) { Hops.linking = false } AnimatedContent( targetState = target, modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() }, @@ -143,9 +160,14 @@ internal fun Pane( .togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f)) .apply { targetContentZIndex = 1f } // Back out of it: the flood draws back into the control as the pane comes forward again. - initialState in origins -> (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f)) - .togetherWith(ExitTransition.None) - .apply { targetContentZIndex = -1f } + initialState in origins && pageExit(origins[initialState]?.owner, targetState, leave) == PageExit.Drain -> + (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f)) + .togetherWith(ExitTransition.None) + .apply { targetContentZIndex = -1f } + // Away to something else: the control is not coming back, so the page sinks with the pane. + initialState in origins -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) + .togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.95f)) + .apply { targetContentZIndex = 1f } else -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) .togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 }) .apply { targetContentZIndex = 1f } @@ -153,26 +175,51 @@ internal fun Pane( }, label = "pane", ) { key -> - val shown = if (page != null && key == s.pageKey) page else pages[key] - if (shown != null) { - DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } } + val drawn = (if (page != null && key == s.pageKey) page else pages[key]) + ?: { Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) } + // A hop leaving this content: it sinks back as the dot leaves it (and comes forward + // again, freshly drawn, when the hop is undone). + val hopSink by androidx.compose.animation.core.animateFloatAsState( + if (Hops.sinking == key) 1f else 0f, Motion.tw(260), label = "hopSink", + ) + val shown: @Composable () -> Unit = { + Box(Modifier.fillMaxSize().graphicsLayer { val k = 1f - 0.05f * hopSink; scaleX = k; scaleY = k; alpha = 1f - 0.35f * hopSink }) { drawn() } + } + run { + DisposableEffect(key) { onDispose { if (key != livePage && key != targetNow) { pages.remove(key); origins.remove(key) } } } val from = origins[key] if (from == null) shown() else { + // The control's own picture (a card's art, an emulator's icon) on the tile as it grows. + val art = when (val m = from.art) { + null -> null + is Int -> androidx.compose.ui.res.painterResource(m) + else -> coil.compose.rememberAsyncImagePainter(m) + } + val leaving = transition.targetState == EnterExitState.PostExit + // Settled as the page starts to leave, from what is coming in, not from where it came in. + val drains = remember(leaving) { !leaving || pageExit(from.owner, target, leave) == PageExit.Drain } // Keeps a leaving page on screen while its flood draws back into the control. transition.animateFloat( - transitionSpec = { if (targetState == EnterExitState.PostExit) Motion.tw(PAGE_RETURN_MS) else snap() }, + transitionSpec = { if (targetState == EnterExitState.PostExit && drains) Motion.tw(PAGE_RETURN_MS) else snap() }, label = "pageReturn", ) { if (it == EnterExitState.PostExit) 1f else 0f } - PageFlood(from, leaving = transition.targetState == EnterExitState.PostExit) { shown() } + PageFlood(from, leaving = leaving, art = art, sinks = !drains) { shown() } } } - else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) } } } } +/** + * An origin left of the pane (a rail item a hop flew to) starts half inside it, at the item's + * height: the page swells out of the pane's edge beside the item. Wholly outside, its tile was a + * bar that raced across from the far edge. + */ +internal fun inPane(o: Origin): Origin = + if (o.bounds.right > 0f) o else o.translate(Offset(-o.bounds.right + o.bounds.width / 2f, 0f)) + @Composable private fun Backdrop(art: File?) { val colors = MaterialTheme.colorScheme @@ -207,6 +254,7 @@ private fun Content( val colors = MaterialTheme.colorScheme val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp val narrow = LocalNarrowPane.current + val density = androidx.compose.ui.platform.LocalDensity.current.density val padH = if (narrow) 16.dp else 22.dp val padV = if (narrow) 12.dp else 18.dp // Setup scrolls inside itself, under its tabs. @@ -226,12 +274,12 @@ private fun Content( return } // The Store scrolls and navigates inside itself (its tabs and an app's page). - if (selected == "store" && s.storeEnabled) { + if (selected == "store") { StorePage(s, a, modifier) return } // Stores (GOG, Epic, Amazon) takes the whole pane: chips, shelves and a game's page inside it. - if (selected == "stores" && s.gameStoresEnabled) { + if (selected == "stores") { StoresPage(s, a, modifier) return } @@ -240,6 +288,11 @@ private fun Content( GamesPage(s, a, selected, onSelect, modifier) return } + // Desktop is the Steam tab's shape: a hero with its lifted sheet, and shelves under it. + if (selected == "desktop") { + DesktopPage(s, a, modifier, onSelect) + return + } // Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid // instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view. Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) { @@ -260,37 +313,6 @@ private fun Content( }) } } - selected == "desktop" -> { - val installed = s.emulators.filter { it.installed } - val available = s.emulators.filter { !it.installed } - val ctx = LocalContext.current - LaunchedEffect(Unit) { UserAppsState.refresh(ctx) } - val apps = UserAppsState.items - var adding by rememberSaveable { mutableStateOf(false) } - Rise(0) { - PageHeader(stringResource(R.string.drawer_desktop)) { - if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false) - } - } - Rise(2) { DesktopCard(s, a) } - // Add closes the last grid: the installable emulators, or the installed ones once none are left. - val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) } - val addAfterReady = available.isEmpty() - if (ready.isNotEmpty() || addAfterReady) { - Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) } - Rise(4) { - LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { if (!s.busy) adding = true }) - } - } - if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() } - if (!addAfterReady) { - Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) } - Rise(6) { - LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { if (!s.busy) adding = true }) - } - } - if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) } - } selected.startsWith("user:") -> { val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected } if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect) @@ -300,21 +322,26 @@ private fun Content( if (e == null) Note(stringResource(R.string.content_not_installed)) else { val pkgId = Library.packageId(e.id) val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } } - Rise(0) { - BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") } - } + // Out of its card on Desktop, with the card's icon; B draws it back in. Rise(1) { Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(top = 4.dp, bottom = 12.dp), ) { - Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp)) + Image(painterResource(e.iconRes), null, modifier = Modifier.size(64.dp)) Column { - Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(e.name, fontSize = if (narrow) 24.sp else 30.sp, fontWeight = FontWeight.Black, color = colors.onBackground) Text( systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) }, fontSize = 14.sp, color = colors.onSurfaceVariant, ) + if (e.installed) Text( + listOfNotNull( + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size).takeIf { e.id != "retroarch" || e.games.isNotEmpty() }, + s.romsDir, + ).joinToString(" · "), + fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) } } } @@ -339,7 +366,24 @@ private fun Content( ) } else Rise(5, Modifier.fillMaxWidth()) { - ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null }) + Column { + // RetroArch's games come from many systems: chips filter them, one + // selection gliding between them. + val systems = remember(e.games) { e.games.map { romSystem(it) }.distinct() } + var system by rememberSaveable(e.id) { mutableStateOf(null) } + if (systems.size > 1) Box(Modifier.padding(top = 10.dp)) { + val labels = listOf(stringResource(R.string.desktop_all_systems)) + systems + SheetChips(labels, if (system == null) 0 else systems.indexOf(system) + 1) { i -> system = if (i == 0) null else systems[i - 1] } + } + val shown = e.games.withIndex().filter { system == null || romSystem(it.value) == system } + ArtGrid(shown.map { (index, g) -> + val key = "rom:${e.id}:$index" + Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, key, e.iconRes) { + TileBounds.mark(key, g.art ?: e.iconRes, 12f * density) + onSelect(key) + } + }, wide = e.games.none { it.art != null }) + } } } else { if (pkg != null) Rise(2) { @@ -381,12 +425,7 @@ private fun Content( val pair = romFor(s, selected) if (pair == null) Note(stringResource(R.string.content_rom_gone)) else { val (e, g) = pair - Rise(0) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - BackLink(e.name) { onSelect("emu:${e.id}") } - Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) - } - } + Rise(0) { Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) } Rise(1) { Title(g.name) } Rise(2) { Row { @@ -408,7 +447,10 @@ private fun Content( Rise(4, Modifier.fillMaxWidth()) { ArtGrid(others.map { x -> val index = e.games.indexOf(x) - Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } + Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { + TileBounds.mark("rom:${e.id}:$index", x.art ?: e.iconRes, 12f * density) + onSelect("rom:${e.id}:$index") + } }, wide = others.none { it.art != null }) } } @@ -419,122 +461,6 @@ private fun Content( } } -/** The Linux desktop itself, above the emulators that run in it. */ -@Composable -private fun DesktopCard(s: FrontEndState, a: FrontEndActions) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - var confirmRemove by rememberSaveable { mutableStateOf(false) } - if (confirmRemove) ConfirmDialog( - stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text), - stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false }, - ) - val actions: @Composable () -> Unit = { - Actions { - // Enabled without a runtime or the desktop: the session's loading screen installs them first. - PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop) - Cog(onClick = a.onDesktopSettings) - if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall), - enabled = !s.runtimeActionsBlocked) { confirmRemove = true } - BusyChip(s) - } - } - // The button goes under the title unless the card is wide enough for both side by side: in some - // languages its label alone takes half a small phone's pane. - BoxWithConstraints(Modifier.fillMaxWidth()) { - val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth - Column( - verticalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14), - ) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) } - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - if (!narrow) actions() - } - if (narrow) actions() - } - } -} - -/** How wide the desktop card must be to keep its button beside the title. */ -private val DesktopCardInlineWidth = 640.dp - -/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */ -private sealed class GridItem(val key: String) { - class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}") - class User(val app: UserApps.App) : GridItem("user:${app.key}") - data object Add : GridItem("add") -} - -/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */ -@Composable -private fun LauncherGrid(items: List, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) { - BoxWithConstraints(Modifier.fillMaxWidth()) { - // A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the - // name no room at all. - val columns = when { - LocalNarrowPane.current -> 1 - maxWidth < 720.dp -> 2 - else -> 3 - } - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) { - items.chunked(columns).forEachIndexed { r, row -> - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - row.forEachIndexed { i, item -> - key(item.key) { - val m = Modifier.weight(1f).fillMaxHeight() - val isFirst = first && r == 0 && i == 0 - when (item) { - is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) } - is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) } - GridItem.Add -> AddTile(m, isFirst, onAdd) - } - } - } - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } - } -} - -/** A grid tile's frame: the emulator tiles' look, focus glide and press. */ -@Composable -private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale") - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier.paneItem("tile:$id").then(if (isFirst) Modifier.firstTile() else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (filled) colors.surface else Color.Transparent) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) { content() } -} - -@Composable -private fun TileText(title: String, detail: String, modifier: Modifier) { - val colors = MaterialTheme.colorScheme - Column(modifier = modifier) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} - internal fun UserApps.Kind.label() = when (this) { UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage @@ -556,26 +482,6 @@ private fun UserAppIcon(app: UserApps.App, size: Int) { } } -@Composable -private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) = - TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) { - UserAppIcon(app, 44) - TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f)) - } - -/** The grid's last tile: opens the Add dialog. */ -@Composable -private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { - val pal = LocalPalette.current - TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12), - ) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) } - TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f)) - } -} - /** The add or remove under way, or why the last one failed. */ @Composable private fun UserAppsProgress() { @@ -595,20 +501,17 @@ private fun UserAppsProgress() { } } -/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */ +/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (asked first). */ @Composable private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) { val colors = MaterialTheme.colorScheme val ctx = LocalContext.current val narrow = LocalNarrowPane.current - var confirm by remember(app.key) { mutableStateOf(false) } var editing by rememberSaveable(app.key) { mutableStateOf(false) } val x86 = (app.arch == "x86_64" || app.arch == "i386") && app.fex != com.droiddeck.launcher.runtime.LinuxFex.OFF val fexReady by produceState(null, app.key, x86, s.sessionRunning) { value = if (x86) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.LinuxFex.ready(ctx) } else true } - LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } } - Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } } Rise(1) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), @@ -631,12 +534,8 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, BusyChip(s) SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true } if (app.repo != null) UpdateButton(app, s) - SecondaryButton( - stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove), - enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null, - ) { - if (!confirm) confirm = true - else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") } + RemoveAppButton(app, enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null) { + UserAppsState.remove(ctx, app); onSelect("desktop") } if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false) } @@ -657,6 +556,31 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, } } +/** Remove, which asks first: are you sure steps out of it in red, Keep first and focused. */ +@Composable +private fun RemoveAppButton(app: UserApps.App, enabled: Boolean, onRemove: () -> Unit) { + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.user_apps_remove) + val title = stringResource(R.string.flathub_remove_title, app.name) + val keep = stringResource(R.string.flathub_keep) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled) { + val from = at ?: return@SecondaryButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 2, + handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) }, + ) { + StepTitle(title) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(1)) { + StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); onRemove() } + } + }) + } + } +} + /** Looks up a newer release of an app added from GitHub, then installs it. */ @Composable private fun UpdateButton(app: UserApps.App, s: FrontEndState) { @@ -688,21 +612,5 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) { private fun systemLabel(e: Library.Emulator): String = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() } -@Composable -private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val system = systemLabel(e) - val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system - TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) { - Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (!e.installed) Text( - stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp), - ) - } -} +/** The system a ROM belongs to: the folder it sits in under the ROMs folder. */ +private fun romSystem(rom: Library.Rom): String = rom.hostPath.parentFile?.name.orEmpty() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index e5f91a76e..16290a1ec 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -77,6 +77,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import com.droiddeck.launcher.core.PhantomProcessLimit // The front end's side rail: the page list and the resume entry under it. @@ -103,27 +106,53 @@ internal fun SideRail( modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp), ) { // Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls. - val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0) + val count = 7 + (if (s.isHomeApp) 1 else 0) BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp) + // A hop in flight moves the selection ahead of the page it is bringing. + val shown = Hops.railPreview ?: selected + val keys = buildList { + add("steam"); add("games") + add("stores") + add("desktop") + add("components") + if (s.isHomeApp) add("android-apps") + add("setup"); add("updates") + } + // Where each item sits in the column, for the selection that glides between them. + val bounds = remember { androidx.compose.runtime.mutableStateMapOf() } + val tint = androidx.compose.runtime.rememberUpdatedState(LocalPalette.current.signal) + var railAt by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).onGloballyPositioned { railAt = it.boundsInRoot().topLeft }) { + // One selection for the whole rail, under the items: it glides from item to item as the + // chips' does, its leading edge first, and a jump of more than one item goes as a drop. + if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown]?.translate(-railAt), tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), ) { - RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") } + val place: (String) -> Modifier = { key -> + // In root px: an item that comes and goes sits in its own wrapper, not the column. + Modifier.onGloballyPositioned { + bounds[key] = it.boundsInRoot() + Hops.railBounds[key] = it.boundsInRoot() + } + } + RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", shown == "steam", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("steam") }) { onSelect("steam") } // Always there, so the items below it never move; an empty library says how to fill it. - RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") } + RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", shown == "games", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("games") }) { onSelect("games") } // The game stores, right under Games: what they install lands there. Its badge counts // the downloads in flight, so a download started here is never out of sight. - if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") } - RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } - if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") } - RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") } + // A section switched on in Setup comes in as the rail's own entrance; off, it folds away. + RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") } + RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", shown == "desktop", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } + RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", shown == "components", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("components") }) { onSelect("components") } // Home mode's extra section goes last, so it shifts nothing above it. - if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } - RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") } - RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") } + if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", shown == "android-apps", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } + RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", shown == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, place = place, onFocus = { onFocusSelect("setup") }) { onSelect("setup") } + RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", shown == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, place = place, onFocus = { onFocusSelect("updates") }) { onSelect("updates") } + } } } var lastRunning by remember { mutableStateOf("") } @@ -142,6 +171,8 @@ private fun RailItem( badge: Boolean = false, /** A number on the item (downloads in flight); 0 shows nothing. */ count: Int = 0, + /** Reports where the item sits, for the rail's gliding selection and a hop's dot. */ + place: (String) -> Modifier = { Modifier }, onFocus: () -> Unit = {}, onClick: () -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -162,8 +193,9 @@ private fun RailItem( if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant, Motion.tw(220), label = "railFg", ) + // The selected look is the rail's glide under the item, so the item draws only hover here. val fill by animateColorAsState( - if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, + if (current) Color.Transparent else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, Motion.tw(220), label = "railFill", ) val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale") @@ -172,6 +204,9 @@ private fun RailItem( modifier = Modifier .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier) .testTag("rail-$key") + .then(place(key)) + // Pops as a hop's dot lands on it. + .landingPop(Hops.railLanded[key] ?: 0) .graphicsLayer { scaleX = scale; scaleY = scale } .size(width = if (iconOnly) 52.dp else 80.dp, height = height) .clip(Shape14) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 1c2e50dec..c148362eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -70,6 +70,8 @@ import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.input.InputMode import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen @@ -132,10 +134,6 @@ class FrontEndState( val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, val launcherFullscreen: Boolean = true, val animationsEnabled: Boolean = true, - /** The Flathub Store, a beta the user turns on in Setup. */ - val storeEnabled: Boolean = false, - /** The Stores section (GOG, Epic Games, Amazon Games), turned on in Setup. */ - val gameStoresEnabled: Boolean = false, /** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */ val gameStoresSpeedTier: String = "fast", /** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */ @@ -178,8 +176,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, @@ -189,6 +191,8 @@ class FrontEndActions( val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, + /** The Components page on one layer ([GPU_TAB], a component id): a hop's destination. */ + val onComponentsTab: (String) -> Unit = {}, /** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */ val onSaveImport: (Library.SteamGame) -> Unit = {}, val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> }, @@ -208,8 +212,6 @@ class FrontEndActions( val onOrientation: (Int) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, val onAnimationsEnabled: (Boolean) -> Unit = {}, - val onStoreEnabled: (Boolean) -> Unit = {}, - val onGameStoresEnabled: (Boolean) -> Unit = {}, val onGameStoresSpeedTier: (String) -> Unit = {}, val onStoresOpenTab: (String) -> Unit = {}, val onNavigated: () -> Unit = {}, @@ -382,6 +384,18 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR } } +/** + * Asks [target] for focus on the next frames until a request lands (a control can take focus only + * once it is laid out), for at most half a second; then leaves focus alone, so a press right after + * is never pulled back. + */ +internal suspend fun focusSoon(target: () -> FocusRequester) { + repeat(30) { + androidx.compose.runtime.withFrameNanos { } + if (runCatching { target().requestFocus() }.isSuccess) return + } +} + @Composable fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) { val frontFocus = remember { FrontFocus() } @@ -423,19 +437,27 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } } - BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() } - // Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link - // does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop. + // A page reached by a hop goes back the way it came; any other page just closes. + val pageBack = { if (!Hops.back(a.onPageBack)) a.onPageBack() } + BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { pageBack() } + // Left any other way, the hop's page has no way back to keep. + LaunchedEffect(s.pageKey, selected) { Hops.forget(s.pageKey ?: selected) } + // A section reached by a hop (Desktop's Flathub card to the Store) goes back the way it came. + BackHandler(enabled = !processSettingsPageVisible && s.pageKey == null && Hops.origin?.page == selected) { + val rail = Hops.origin?.rail ?: return@BackHandler + Hops.back { selected = rail } + } + val density = androidx.compose.ui.platform.LocalDensity.current.density + remember(density) { Hops.density = density; true } + // Back (and B) from a ROM, an emulator, an added app or Flathub steps out one level instead + // of leaving the app: a ROM -> its emulator, the others -> Desktop. BackHandler( enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) && - (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")), + (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"), ) { selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop" } LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" } - // The Store turned off in Setup takes its page with it; so does Stores. - LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" } - LaunchedEffect(s.gameStoresEnabled) { if (!s.gameStoresEnabled && selected == "stores") selected = "games" } // The last game uninstalled leaves the Games tab on its empty state. LaunchedEffect(s.steamGames.isEmpty()) { if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games" @@ -443,11 +465,14 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com remember { Motion.refresh(ctx); true } val railSelection = when { - s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" - s.pageKey?.startsWith("settings:steam") == true -> "steam" - s.pageKey?.startsWith("settings:") == true -> "desktop" + // A hop's page belongs to the rail item it flew to. + Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest + // Performance opens from Play's sheet, and is Steam's. + s.pageKey == "performance" -> "steam" + s.pageKey == "controller-mapping" -> "setup" selected.startsWith("app:") -> "games" - selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop" + // Flathub is a page of Desktop's, as an emulator's is. + selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store" -> "desktop" else -> s.pageKey ?: selected } // Bumped each time a rail item is picked, so a controller moves on into the new page. @@ -458,6 +483,10 @@ 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 + Hops.sinking = null selected = key } } @@ -519,7 +548,22 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val railFocus = Modifier .focusProperties { enter = { frontFocus.railFor(railSelection) } } .focusGroup() - Row(modifier = Modifier.fillMaxSize()) { + // Back from a hop onto a surface that is not a sheet (a game's Components card, Desktop's + // Flathub card): focus + // goes back to the control that asked, once the page has gone. + LaunchedEffect(Hops.restore) { + val r = Hops.restore ?: return@LaunchedEffect + if (r.surface == HOP_SURFACE_SHEET) return@LaunchedEffect + Hops.restore = null + if (r.rail != selected && !(r.rail == "games" && selected.startsWith("app:"))) selected = r.rail + kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) + if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect + frontFocus.last = r.control + focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() } + } + var screenAt by remember { mutableStateOf(Offset.Zero) } + CompositionLocalProvider(LocalShowSection provides { key -> selected = key }) { + Row(modifier = Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }) { SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight()) Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) Column(modifier = Modifier.weight(1f).fillMaxHeight()) { @@ -535,6 +579,12 @@ 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() ChooseAppDisplayDialog( @@ -597,8 +647,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } val hasBackTarget = (s.pageKey != null && page != null) || + // A section reached by a hop goes back to where it came from, not to the rail. + (s.pageKey == null && Hops.origin?.page == selected) || ((s.pageKey == null || page == null) && - (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:"))) + (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store")) // At the top of a section, Back goes to the rail - the launcher itself is never backed out of. BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) { if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c0eb21e92..dfab93aa9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -71,6 +71,8 @@ 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.sp +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import com.droiddeck.launcher.core.AppLanguage import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.core.PhantomProcessLimit @@ -166,21 +168,26 @@ internal fun SetupPanel( var showLimitDetails by rememberSaveable { mutableStateOf(false) } var editSgdbKey by remember { mutableStateOf(false) } if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false }) - // Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build - // and credits are on the Updates page. - val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher)) + // Four tabs by scope, a chip row with one gliding selection; LB and RB turn them from + // anywhere on the page. Build and credits are on the Updates page. + val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_diagnostics)) var tab by rememberSaveable { mutableStateOf(0) } - val tabFocus = remember { List(tabs.size) { FocusRequester() } } + val chipFocus = remember { List(4) { FocusRequester() } } var tabTurned by remember { mutableStateOf(false) } val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true } val inputModeManager = LocalInputModeManager.current - // The control a controller was on went with the old tab: it lands on the new tab itself. + // The control a controller was on went with the old tab: it lands on the new tab's chip. LaunchedEffect(tab) { if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) { androidx.compose.runtime.withFrameNanos { } - runCatching { tabFocus[tab].requestFocus() } + runCatching { chipFocus[tab].requestFocus() } } } + val wide = !LocalNarrowPane.current + val runtimeOk = s.ready && !s.busy && !s.removalPending + val checks = listOf(gpuOk, runtimeOk, !limitBlocks, signedIn) + val passing = checks.count { it } + var showChecks by rememberSaveable { mutableStateOf(false) } Rise(0, Modifier.fillMaxSize()) { Column( modifier = Modifier.fillMaxSize().bumpers( @@ -189,15 +196,12 @@ internal fun SetupPanel( ), ) { PageHeader(stringResource(R.string.setup_title)) { - TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus) + Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, pick) } } Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { when (tab) { 0 -> { - SectionTitle(stringResource(R.string.setup_system_check), null) - // What Steam needs, one row each: green when done, one button when not. The - // process-limit controls only open under their row. - Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { + val gpuCheck: @Composable (Boolean) -> Unit = { divider -> CheckRow( if (gpuOk) CheckState.OK else CheckState.WARN, when (gpu.support) { @@ -210,7 +214,10 @@ internal fun SetupPanel( com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() }) else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName) }, + divider = divider, ) + } + val runtimeCheck: @Composable (Boolean) -> Unit = { divider -> CheckRow( when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK }, stringResource(R.string.setup_runtime), @@ -221,7 +228,7 @@ internal fun SetupPanel( s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed)) else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed)) }, - divider = !s.busy, + divider = divider && !s.busy, ) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) } if (s.busy) { Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) { @@ -232,8 +239,10 @@ internal fun SetupPanel( if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) } - Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } + } + val limitCheck: @Composable (Boolean) -> Unit = { divider -> CheckRow( if (limitBlocks) CheckState.WARN else CheckState.OK, stringResource(R.string.setup_limit), @@ -243,6 +252,7 @@ internal fun SetupPanel( PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown) else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus) }, + divider = divider && !showLimitDetails, ) { if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails } else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { @@ -271,6 +281,9 @@ internal fun SetupPanel( processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) } } } + if (divider && showLimitDetails) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + } + val accountCheck: @Composable (Boolean) -> Unit = { divider -> CheckRow( if (signedIn) CheckState.OK else CheckState.WARN, stringResource(R.string.setup_account), @@ -281,11 +294,42 @@ internal fun SetupPanel( else -> stringResource(R.string.setup_signed_in, it) } } ?: stringResource(R.string.setup_sign_in), - divider = false, + divider = divider, ) } - SectionTitle(stringResource(R.string.setup_tools), null) - ToolGrid(s, a) + val all = listOf(gpuCheck, runtimeCheck, limitCheck, accountCheck) + // Everything passes: one line says so, and Details opens the checks in place. + ReadinessCard( + passing, checks.size, + listOfNotNull( + gpu.displayName(ctx), + s.installed?.let { stringResource(R.string.setup_ready_runtime, it) }, + stringResource(R.string.setup_ready_limit_off).takeIf { !limitBlocks }, + s.offlineAccount?.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.setup_ready_signed_in, it) }, + ).joinToString(" · "), + open = showChecks, onDetails = { showChecks = !showChecks }, + ) { + val shownChecks = all.filterIndexed { i, _ -> checks[i] } + shownChecks.forEachIndexed { i, check -> check(i < shownChecks.lastIndex) } + } + // Each check that warns is its own card, its Fix it as it always was. + if (passing < checks.size) { + SectionTitle(stringResource(R.string.setup_needs_you), null) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + all.forEachIndexed { i, check -> + if (!checks[i]) Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, AttentionAmber.copy(alpha = 0.5f), Shape14)) { check(false) } + } + } + } + SettingsGroup(stringResource(R.string.setup_account)) { + accountCheck(true) + ToggleRow( + host, "offline", stringResource(R.string.setup_offline), + s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first), + s.offline, enabled = s.offlineAccount != null, + ) { a.onOffline() } + } + SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) } } 1 -> { val controller = s.controller @@ -295,110 +339,79 @@ internal fun SetupPanel( if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable)) } 2 -> { - SettingsGroup(stringResource(R.string.setup_session)) { - 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, - ) - SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) { - Box { - ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" } - FrameGenMenu(s, a, host) + val look: @Composable () -> Unit = { + SettingsGroup(stringResource(R.string.setup_group_look)) { + SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) { + Box { + ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" } + AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus -> + Themes.all.forEachIndexed { index, theme -> + MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) { + a.onTheme(theme.id) + host.open = null + } + } + } + } } + ChoiceRow( + host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), + com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent -> + percent to stringResource( + if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default + else R.string.ctrl_percent, percent, + ) + }, s.appScale, onPick = a.onAppScale, + ) + ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, + s.animationsEnabled, onChange = a.onAnimationsEnabled) + LanguageRow(host, s.language, a.onLanguage) } + } + val device: @Composable () -> Unit = { + SettingsGroup(stringResource(R.string.setup_group_device)) { + ToggleRow( + host, "home-screen", stringResource(R.string.setup_home), + if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off), + s.homeScreenEnabled, + ) { a.onHomeScreen(it) } + if (s.homeScreenEnabled) { + ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp) + } + ChoiceRow( + host, "orientation", stringResource(R.string.setup_orientation), null, + SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) }, + s.orientation, onPick = a.onOrientation, + ) + ToggleRow( + host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen), + if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off), + s.launcherFullscreen, + ) { a.onLauncherFullscreen(it) } + } + } + val art: @Composable () -> Unit = { + SettingsGroup(stringResource(R.string.setup_group_art)) { + ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) } + ActionRow( + stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null, + stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true }, + ) + } + } + // Two columns on a wide page: how it looks; this device and the library's art. + if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { look() } + Column(Modifier.weight(1f)) { device(); art() } + } else { look(); device(); art() } + } + 3 -> { + SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) { ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() } ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() } ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs, progress = com.droiddeck.launcher.session.SessionLogShare.progress) ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs) - ToggleRow( - host, "offline", stringResource(R.string.setup_offline), - s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first), - s.offline, enabled = s.offlineAccount != null, - ) { a.onOffline() } - } - } - 3 -> { - SettingsGroup(stringResource(R.string.setup_launcher)) { - LanguageRow(host, s.language, a.onLanguage) - ChoiceRow( - host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), - com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent -> - percent to stringResource( - if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default - else R.string.ctrl_percent, percent, - ) - }, s.appScale, onPick = a.onAppScale, - ) - SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) { - Box { - ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" } - AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus -> - Themes.all.forEachIndexed { index, theme -> - MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) { - a.onTheme(theme.id) - host.open = null - } - } - } - } - } - ToggleRow( - host, "home-screen", stringResource(R.string.setup_home), - if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off), - s.homeScreenEnabled, - ) { a.onHomeScreen(it) } - ChoiceRow( - host, "orientation", stringResource(R.string.setup_orientation), null, - SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) }, - s.orientation, onPick = a.onOrientation, - ) - ToggleRow( - host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen), - if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off), - s.launcherFullscreen, - ) { a.onLauncherFullscreen(it) } - ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, - s.animationsEnabled, onChange = a.onAnimationsEnabled) - if (s.homeScreenEnabled) { - ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp) - } - } - SettingsGroup(stringResource(R.string.setup_linux_apps)) { - ToggleRow( - host, "store-enabled", stringResource(R.string.setup_store), - if (s.storeEnabled) stringResource(R.string.setup_store_on) - else stringResource(R.string.setup_store_off), - s.storeEnabled, - ) { a.onStoreEnabled(it) } - } - SettingsGroup(stringResource(R.string.content_games)) { - ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) } - ActionRow( - stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null, - stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true }, - ) - } - SettingsGroup(stringResource(R.string.setup_stores)) { - ToggleRow( - host, "stores-enabled", stringResource(R.string.setup_stores_show), - null, s.gameStoresEnabled, - ) { a.onGameStoresEnabled(it) } - // The same rows as the Stores page's own cog, for whoever looks here first. - SettingsRow(stringResource(R.string.setup_stores_open_on), null) { - SegmentedTabs( - listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)), - s.storesOpenTab, - ) { a.onStoresOpenTab(it) } - } - ToggleRow(host, "stores-show-mature", stringResource(R.string.stores_show_mature), null, s.storesShowMature) { a.onStoresShowMature(it) } - SettingsRow(stringResource(R.string.setup_stores_speed), null) { - SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) } - } - StoresEngineRow() } } } @@ -407,50 +420,43 @@ internal fun SetupPanel( } } -/** The launcher's own tools as cards: four across, two by two on a narrow page. */ +/** + * The checks as one card while they all pass: a ring with how many pass, "Ready to play" and one + * line of what that means, and a Details chip that opens the checks themselves in place ([checks]). + */ @Composable -private fun ToolGrid(s: FrontEndState, a: FrontEndActions) { - val columns = if (LocalNarrowPane.current) 2 else 4 - val tools = listOf( - ToolSpec(Icons.Outlined.Folder, stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint), a.onFiles), - ToolSpec(Icons.Outlined.Extension, stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), a.onProtons), - ToolSpec(Icons.Outlined.Speed, stringResource(R.string.setup_tool_performance), stringResource(R.string.setup_tool_performance_hint), a.onPerformance), - ToolSpec(Icons.Outlined.VideogameAsset, stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint), a.onRoms), - ) - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - for (row in tools.chunked(columns)) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight()) - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } -} - -private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit) - -@Composable -private fun ToolCard(t: ToolSpec, modifier: Modifier) { +private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale") - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier.paneItem("tool:${t.title}") - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick) - .controllerConfirm(onClick = t.onClick) - .padding(horizontal = 14.dp, vertical = 12.dp), - ) { - Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) - Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + val ready = passing == total + Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + ) { + val tint = if (ready) pal.good else AttentionAmber + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp)) { + androidx.compose.material3.CircularProgressIndicator( + progress = { passing / total.toFloat().coerceAtLeast(1f) }, strokeWidth = 4.dp, + color = tint, trackColor = tint.copy(alpha = 0.18f), modifier = Modifier.size(52.dp), + ) + Text("$passing/$total", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + } + Column(Modifier.weight(1f)) { + Text( + stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready), + fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + ) + if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (passing > 0) SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true, onClick = onDetails) + } + AnimatedVisibility(open, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) { + Column(Modifier.fillMaxWidth()) { + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + checks() + } + } } } @@ -479,7 +485,15 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost var wincompOpen by remember(wincompKey) { mutableStateOf(null) } val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() } val cards = buildList<@Composable (Modifier) -> Unit> { - add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } } + add { m -> + val back = stringResource(R.string.hop_from_games) + // Components is its own section: the card hops there, and B on it comes back here. + SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) { + val at = Hops.sources["card:components"] + if (at == null) a.onComponents(true) + else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) } + } + } add { m -> Box(m) { SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt index 9fe6ab6be..eec0f2087 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -57,6 +57,10 @@ 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.sp +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester /** One matched driver pair in the list (DriverPairs), and what this device makes of it. */ class PairRow( @@ -119,155 +123,38 @@ class GpuDriversActions( ) /** - * The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the - * games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps - * the pair recommended for the GPU installed and current; Manual picks a pair from the list; the - * two lists each on their own, and imports, are under Advanced. + * Everything about the chip - SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the + * drivers in use - gathered once, off the main thread, with a button that copies it for a bug + * report. Stepped out of the Components page's device chip; [first] is where a pad starts. */ @Composable -internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) { +internal fun DeviceDetails(first: FocusRequester) { val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - var showAll by rememberSaveable { mutableStateOf(false) } - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { - DeviceCard(s) - if (!s.unsupported) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) } - ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) } - } - if (s.auto) AutoCard(s) - else { - val shown = s.pairs.filter { it.suits || showAll } - if (shown.isEmpty()) Text( - if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed), - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp), - ) - for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) } - if (s.pairs.any { !it.suits }) FocusText( - if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal, - ) { showAll = !showAll } - } - } - SettingsGroup(stringResource(R.string.gpu_advanced)) { - SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) { - DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") } - } - SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) { - DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") } - } - } - } -} - -/** - * One driver list as a drop-down: the installed drivers to pick from, and the full page for - * downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both. - */ -@Composable -private fun DriverDropdown(rows: List, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - var open by remember { mutableStateOf(false) } - Box { - SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true } - DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) { - for (row in rows) DropdownMenuItem( - leadingIcon = { Radio(row.id == selected) }, - text = { - Column { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant) - } - if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - }, - onClick = { open = false; if (row.id != selected) onSelect(row.id) }, - ) - HorizontalDivider(color = pal.line) - DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() }) - } - } -} - -/** - * This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip - - * SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once, - * off the main thread, with a button that copies it for a bug report. - */ -@Composable -private fun DeviceCard(s: GpuDriversState) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current val context = LocalContext.current val clipboard = LocalClipboardManager.current - val tint = when { - s.unsupported -> pal.error - s.supported -> pal.good - else -> AttentionAmber - } - var open by rememberSaveable { mutableStateOf(false) } var details by remember { mutableStateOf?>(null) } var copied by remember { mutableStateOf(false) } - LaunchedEffect(open) { - if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } - } - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val toggle = { open = !open; copied = false } + LaunchedEffect(Unit) { details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } } Column( - Modifier.fillMaxWidth().clip(Shape14).background(colors.surface) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent) - .glideBorder(hot, Shape14, pal.signal, pal.line), + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).verticalScroll(rememberScrollState()), ) { - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth().paneItem("gpu-device") - .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) - .controllerConfirm(onClick = toggle) - .padding(14.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) { - Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) - } - Column(Modifier.weight(1f)) { - Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) - Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant) - } - // The verdict shares the width with the GPU's name and wraps, rather than taking all it - // needs: a long one (in some languages) would squeeze the name to a letter a line. - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) { - Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) - Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis) - } - Icon( - if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, - contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show), - tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp), - ) - } - AnimatedVisibility(open) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) { - HorizontalDivider(color = pal.line) - val sections = details - if (sections == null) { - Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant) - LinearProgressIndicator(Modifier.fillMaxWidth()) - } else { - for (section in sections) DeviceInfoSection(section) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint), - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f), - ) - SecondaryButton(stringResource(R.string.device_info_copy), compact = true) { - clipboard.setText(AnnotatedString(DeviceInfo.text(sections))) - copied = true - } - } + val sections = details + if (sections == null) { + Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant) + LinearProgressIndicator(Modifier.fillMaxWidth()) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint), + fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f), + ) + SecondaryButton(stringResource(R.string.device_info_copy), compact = true, modifier = Modifier.focusRequester(first)) { + clipboard.setText(AnnotatedString(DeviceInfo.text(sections))) + copied = true } } + for (section in sections) DeviceInfoSection(section) } } } @@ -288,115 +175,9 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) { Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant) for ((label, value) in section.rows) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp)) + Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(150.dp)) Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f)) } } } } - -/** What Auto has set and what it is doing. */ -@Composable -private fun AutoCard(s: GpuDriversState) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val active = s.activePair - val recommended = s.pairs.firstOrNull { it.recommended } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp), - ) { - Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant) - Text( - active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin), - fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, - ) - Text( - if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair) - else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint), - fontSize = 13.sp, color = colors.onSurfaceVariant, - ) - if (s.busy != null) { - LinearProgressIndicator( - progress = { (s.percent.coerceAtLeast(0)) / 100f }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape), - ) - } - if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp)) - } -} - -@Composable -private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - // Selection is the radio; the ring is only ever focus. - val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp) - .clip(Shape14).background(colors.surface).background(fill) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick) - .controllerConfirm(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 10.dp), - ) { - Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp)) - Column(Modifier.weight(1f)) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Radio(selected) - } -} - -@Composable -private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val usable = enabled && p.complete && !p.active - val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp) - .clip(Shape12).background(colors.surface).background(fill) - .glideBorder(hot, Shape12, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick) - .controllerConfirm(enabled = usable, onClick = onClick) - .padding(horizontal = 14.dp, vertical = 8.dp), - ) { - Radio(p.active) - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - if (p.recommended) Text( - stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good, - modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - when { - busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { - Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant) - LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) - } - p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal) - !p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant) - p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - } - } -} - -@Composable -private fun Radio(selected: Boolean) { - val pal = LocalPalette.current - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) { - if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal)) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt b/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt new file mode 100644 index 000000000..a5ee4c8f2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt @@ -0,0 +1,174 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R + +// A link from a control in one section to a page in another (the Play sheet's Graphics drivers, +// a game's Components card): the control squashes into a dot that flies to the destination's rail +// item, the rail's selection glides there as it passes the top of its arc, and the page floods +// out of the rail item once it lands. B on that page flies the dot back and puts the source +// surface back as it was, focus on the control that asked. + +/** + * Where a hop came from: the rail item that was showing ([rail]), the surface on it (a sheet, the + * Games page) and its tab, the control's key and its root bounds, and the words for the way back. + * [page] is the page key the hop opened, so leaving it any other way forgets the way back. + */ +internal class HopOrigin( + val rail: String, + val surface: String, + val tab: String?, + val control: String, + val bounds: Rect, + val back: String, + val dest: String, + val page: String, +) + +/** The surfaces a hop can start from and come back to. */ +internal const val HOP_SURFACE_GAMES = "games" +internal const val HOP_SURFACE_DESKTOP = "desktop" +internal const val HOP_SURFACE_SHEET = "sheet" + +internal object Hops { + /** The hop the page on screen was reached by, while B can still undo it. */ + var origin by mutableStateOf(null) + + /** The pane content a hop is leaving (a rail key): it sinks back as the dot leaves it. */ + var sinking by mutableStateOf(null) + + /** Set while a hop's dot is flying: the rail item its selection has already glided to. */ + var railPreview by mutableStateOf(null) + + /** + * Set as a page leaves through a hop (either way), so the pane sinks it rather than draining + * it; the pane clears it once the change has started. + */ + var linking by mutableStateOf(false) + + /** A surface to put back as it was (a sheet re-lifted on its tab), once, and the control to focus. */ + var restore by mutableStateOf(null) + + /** Per rail item, bumped when a dot lands on it: the item pops. */ + val railLanded = mutableStateMapOf() + + /** Bumped when a hop's page has flooded in: its target (a layer card) sends out one ring. */ + var arrived by mutableIntStateOf(0) + + /** Where each rail item sits, root px. */ + val railBounds = HashMap() + + /** Where each control that can start a hop sits, root px. */ + val sources = HashMap() + + /** + * Hops from the control at [origin].bounds to [origin].dest: [go] opens the page there once the dot lands. + * With animations off there is no dot and no flood, only the page. + */ + fun go(origin: HopOrigin, go: () -> Unit) { + val railAt = railBounds[origin.dest] + val finish: () -> Unit = { + railPreview = null + this.origin = origin + if (railAt != null && Motion.scale != 0f) PageOrigin.mark(Origin(railAt, 14f * density, owner = origin.dest)) + linking = true + go() + railLanded[origin.dest] = (railLanded[origin.dest] ?: 0) + 1 + arrived++ + } + if (railAt == null || Motion.scale == 0f) { finish(); return } + // The surface the control is on sinks as its dot leaves, and stays down as it goes. + sinking = origin.rail + Flights.fly(Flight( + origin.bounds, to = { railAt.center }, + onHalf = { railPreview = origin.dest }, + onLand = finish, + )) + } + + /** + * B on a page reached by a hop: the page sinks, the source surface comes back as it was, and + * the dot flies home from the rail item to the control. False when there is no hop to undo. + */ + fun back(close: () -> Unit): Boolean { + val o = origin ?: return false + origin = null + sinking = null + val railAt = railBounds[o.dest] + railPreview = if (railAt != null && Motion.scale != 0f) o.dest else null + restore = o + linking = true + close() + if (railAt == null || Motion.scale == 0f) { railPreview = null; return true } + Flights.fly(Flight( + railAt, to = { (sources[o.control] ?: o.bounds).center }, + onHalf = { railPreview = null }, + )) + return true + } + + /** The page the hop opened has gone some other way: there is no way back to keep. */ + fun forget(pageKey: String?) { + val o = origin ?: return + if (pageKey != o.page) { origin = null; sinking = null } + } + + /** Screen density, for the rail item's corner; set by the front end. */ + var density = 1f +} + +/** Shows a rail section directly (no page closing, nothing forgotten): what a hop's way back calls. */ +internal val LocalShowSection = androidx.compose.runtime.staticCompositionLocalOf<(String) -> Unit> { {} } + +/** Remembers where this control sits, so a hop from it can fly from it and back to it. */ +internal fun Modifier.hopSource(key: String): Modifier = onGloballyPositioned { Hops.sources[key] = it.boundsInRoot() } + +/** + * "B · back to " at the right of a page's header while it was reached by a hop; a tap is B. + * Nothing when it was not. + */ +@Composable +internal fun HopBackChip(onBack: () -> Unit) { + val o = Hops.origin ?: return + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + Text( + stringResource(R.string.hop_back, o.back), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape) + .background(pal.signal.copy(alpha = if (hot) 0.16f else 0.08f)) + .glideBorder(hot, shape, pal.signal, pal.line2) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onBack) + .controllerConfirm(onClick = onBack) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt new file mode 100644 index 000000000..60667ee92 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -0,0 +1,805 @@ +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.KeyboardArrowUp +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.foundation.layout.wrapContentHeight +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.zIndex +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.layout.onSizeChanged +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.animation.core.animateFloat +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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" +} + +/** + * 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) + +/** A finger's hold on the sheet: where it started (0 rest, 1 open), where it is, whether it has it. */ +private class SheetDrag { + var from = 0f + var progress = 0f + var engaged = false +} + +/** 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>) { + 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 { 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>, + onOpen: () -> Unit, + onPrimary: () -> Unit, + background: @Composable () -> Unit, + /** + * Drawn over [background] at the pane's own size: the wall's fades, which must not sink with + * it. Given how far the sheet is up (0 to 1), for anything there that should give way to it. + */ + scrim: @Composable (lifted: () -> Float) -> Unit = {}, + words: @Composable () -> Unit, + /** + * Buttons beside the big one (Desktop's Flathub and +). They stay where they are and fade as + * the sheet lifts: only the big button rides it. + */ + beside: @Composable () -> Unit = {}, + button: @Composable (onClick: () -> Unit) -> Unit, + rows: @Composable (tab: String) -> Unit, + modifier: Modifier = Modifier, + /** + * Down on the button lifts the sheet (Play). False: Down goes to the lip, which A lifts, and Down + * from the lip goes on past it to what is under the hero (Desktop's shelves). + */ + downOpens: Boolean = true, + /** Told when the sheet starts to lift (true) and once it has gone (false). */ + onOpenChange: (Boolean) -> Unit = {}, + 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 + // Held as states too, so the sheet's focus rule reads what they are now, not at the last composition. + val openState = remember { mutableStateOf(false) } + var open by openState + var shown by remember { mutableStateOf(false) } + val launchingState = remember { mutableStateOf(false) } + var launching by launchingState + 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(null) } + // Set as the sheet closes and Play lands: the lip may come back before the sheet has quite gone. + var lipBack by remember { mutableStateOf(false) } + // Set as a finger's drag lets go: the sheet is already where it is going. + var dragSettled by remember { mutableStateOf(false) } + val scope = androidx.compose.runtime.rememberCoroutineScope() + val onOpenNow = rememberUpdatedState(onOpen) + 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 fold = remember { Animatable(0f) } + val foldTop = remember { Animatable(0f) } + val pieces = remember { List(PIECES) { Animatable(0f) } } + val chipFocus = remember(tabs.size) { List(tabs.size) { 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(null) } + 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; the sheet lets it go once it is closing. + open = false + focusPlay() + } + // 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 (dragSettled) { + // A finger brought it here and has already animated it: only what opening needs. + dragSettled = false + if (open) { + if (inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { chipFocus[tab].requestFocus() } } + } else focusPlay() + return@LaunchedEffect + } + if (open && !launching) { + lipBack = false + 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)) } + } + // Each piece on its beat from the Lift, as the reel has it: 260ms + 55ms a piece after it starts. + pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 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) } ?: chipFocus[tab] + 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)) } + // The lip tucks back under Play as it lands, not once every spring has settled. + launch { delay(Motion.ms(420).toLong()); lipGone.snapTo(0f); lipBack = true } + } + shown = false + lipBack = 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() } + val openNow = open || shown + LaunchedEffect(openNow) { onOpenChange(openNow) } + + // 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 lipFocus = remember { FocusRequester() } + val settingsLabel = stringResource(R.string.drawer_page_settings) + var lipFocused by remember { mutableStateOf(false) } + val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused) + LaunchedEffect(lipWanted) { + if (lipWanted) { + // Back from the sheet it comes straight away; otherwise a beat after Play has focus. + if (!lipBack) delay(Motion.ms(160).toLong()) + lipShow.animateTo(1f, Motion.tw(220)) + } else { + delay(Motion.ms(140).toLong()) + lipShow.animateTo(0f, Motion.tw(220)) + } + } + + // How far the sheet travels, for a finger's drag to follow: set once the hero is measured. + val travelPx = remember { floatArrayOf(1f) } + val drag = remember { SheetDrag() } + /** The sheet [p] of the way up (0 at rest, 1 open) under a finger. */ + fun follow(p: Float) { + drag.progress = p + scope.launch { + lift.snapTo(p); ride.snapTo(p); sink.snapTo(p.coerceIn(0f, 1f)); hot.snapTo(0f) + lipGone.snapTo(1f) + pieces.forEach { it.snapTo(p.coerceIn(0f, 1f)) } + } + } + /** A finger takes the sheet where it is: up from rest, or down from open. */ + fun beginDrag() { + drag.engaged = true + drag.from = if (open) 1f else 0f + drag.progress = drag.from + if (!open) { + shown = true + scope.launch { fold.snapTo(0f); foldTop.snapTo(0f) } + onOpenNow.value() + } + } + /** + * The finger lets go with [v] px/s (up negative): a flick goes the way it was flicked; a slow + * drag stays once it is a third of the way from where it started. + */ + fun settleDrag(v: Float) { + drag.engaged = false + val flick = with(density) { 600.dp.toPx() } + val opens = when { + v < -flick -> true + v > flick -> false + drag.from > 0.5f -> drag.progress > 0.7f + else -> drag.progress > 0.3f + } + dragSettled = true + scope.launch { + val to = if (opens) 1f else 0f + coroutineScope { + launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) } + launch { ride.animateTo(to, Motion.sp(if (opens) 0.7f else 0.55f, if (opens) 380f else 300f)) } + launch { sink.animateTo(to, Motion.tw(220)) } + pieces.forEachIndexed { i, piece -> launch { if (opens) delay(Motion.ms(i * 35).toLong()); piece.animateTo(to, Motion.tw(if (opens) 200 else 90)) } } + } + if (!opens) { shown = false; lipGone.snapTo(0f); hot.snapTo(1f); lipBack = false } + } + if (open != opens) open = opens else dragSettled = false + } + // Pulled down past the top of the rows, the sheet itself comes down with the finger; up again + // before letting go, it goes back up before the rows scroll. + val rowsPull = remember { + object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection { + override fun onPreScroll(available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset { + if (!drag.engaged || available.y >= 0f) return Offset.Zero + val before = drag.progress + follow((before - available.y / travelPx[0]).coerceAtMost(1f)) + return Offset(0f, (drag.progress - before) * -travelPx[0]) + } + override fun onPostScroll(consumed: Offset, available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset { + if (available.y <= 0f || source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag || !openState.value || launchingState.value) return Offset.Zero + if (!drag.engaged) beginDrag() + follow((drag.progress - available.y / travelPx[0]).coerceIn(0f, 1f)) + return available + } + override suspend fun onPreFling(available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity { + if (!drag.engaged) return androidx.compose.ui.unit.Velocity.Zero + settleDrag(available.y) + return available + } + } + } + BoxWithConstraints( + modifier.fillMaxSize().onGloballyPositioned { heroAt = it.boundsInRoot().topLeft } + // A swipe anywhere on the hero moves the sheet under the finger - up from rest, down + // when open - and lets go open or shut by how far it went and how fast. A swipe that + // starts the wrong way (down at rest) is left alone, for the page under it. + .pointerInput(Unit) { + val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker() + var decided = false + detectVerticalDragGestures( + onDragStart = { tracker.resetTracking(); decided = false; drag.engaged = false }, + onDragEnd = { if (drag.engaged) settleDrag(tracker.calculateVelocity().y) }, + onDragCancel = { if (drag.engaged) settleDrag(0f) }, + ) { change, amount -> + if (launching) return@detectVerticalDragGestures + if (!decided) { + decided = true + // Up at rest opens; down when open closes. Anything else is not for the sheet. + if (if (open) amount > 0f else amount < 0f) beginDrag() + } + if (!drag.engaged) return@detectVerticalDragGestures + change.consume() + tracker.addPosition(change.uptimeMillis, change.position) + follow((drag.progress - amount / travelPx[0]).coerceIn(0f, 1.04f)) + } + }, + ) { + 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 + travelPx[0] = (h - openTop).coerceAtLeast(1f) + + // The wall behind sinks back about the pane's centre. + val foot = h + Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() } + // The fades stay put: scaled with the wall they stopped short of the pane's edges and left + // a bright band of unfaded wall down the side and along the top. + scrim { sink.value } + // 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(foot, 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(foot + corner, pill.bottom, ft) else foot + 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() } + // The rows start under the chips, which are 44dp to touch: from 54dp they ran under the + // chips' feet and, scrolled, covered them. + val rowsY = with(density) { maxOf(RowsTop.toPx(), chipsY + 52.dp.toPx()) } + val sheetW = sheetRight - sheetLeft + Box( + Modifier + .offset { IntOffset(sheetLeft.roundToInt(), top.roundToInt()) } + .size(with(density) { sheetW.toDp() }, with(density) { (foot - openTop).coerceAtLeast(0f).toDp() }) + // Touches on the sheet stay on it; none reach the dim behind. + .pointerInput(Unit) { detectTapGestures { } } + .controllerBack { close() } + .bumpers( + // LB / RB: focus goes to the new tab's chip, so a pad on a row (which goes with + // its tab) is never left with nothing. + onPrevious = { if (tab > 0) { travel = -1; tab--; runCatching { chipFocus[tab].requestFocus() } } }, + onNext = { if (tab < tabs.lastIndex) { travel = 1; tab++; runCatching { chipFocus[tab].requestFocus() } } }, + ) + // A pad stays in the sheet until it folds; Left from it reaches Play, the handle. + .focusProperties { exit = { dir -> if (!openState.value || launchingState.value) FocusRequester.Default else if (dir == FocusDirection.Left && ff != null) ff.primary else FocusRequester.Cancel } } + .focusGroup(), + ) { + Box( + Modifier + .offset { IntOffset(chipsX.roundToInt(), chipsY.roundToInt()) } + // Over the rows, whatever they scroll under them. + .zIndex(1f) + .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() } + // Up from the chips folds the sheet (a finger drags it down, see the hero's drag). + .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, chipFocus) { i -> travel = if (i > tab) 1 else -1; tab = i } + } + Box( + Modifier + .offset { IntOffset(0, rowsY.roundToInt()) } + .nestedScroll(rowsPull) + .fillMaxWidth() + .height(with(density) { (foot - 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. Same group always, only + // its rule changes (canFocus here made the scroll's own group a stop). + .focusProperties { enter = { if (leavingNow.value) FocusRequester.Cancel else FocusRequester.Default } } + .focusGroup() + .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)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + Modifier + .onGloballyPositioned { rest = it.boundsInRoot() } + .graphicsLayer { translationY = raise * ride.value } + .onFocusChanged { playFocused = it.hasFocus } + .hoverable(playSrc) + .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) runCatching { chipFocus[tab].requestFocus() } + else if (downOpens) open = true + else runCatching { lipFocus.requestFocus() } + } + true + } + }, + ) { + button { + if (launching) return@button + if (open || shown) { launching = true; open = false } else onPrimary() + } + } + // Gone with the words while the sheet is up; nothing a pad or a finger can reach then. + val besideShown = !(open || shown) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.graphicsLayer { alpha = 1f - sink.value } + .focusProperties { canFocus = besideShown }, + ) { if (besideShown || sink.value < 0.99f) beside() } + } + } + + // The lip, under Play: stretched by the pull, gone as the sheet lifts. + val r0 = restRect + // A lip a pad can reach stays composed (unseen at rest) so Down always has it to land on. + if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f || (!downOpens && !open && !shown))) { + var handleW by remember { mutableStateOf(0f) } + val lipH = 22.dp + pull.value.dp + val lift = { if (!open) open = true } + Box( + Modifier + .offset { IntOffset((r0.center.x - handleW / 2f).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) } + .onSizeChanged { handleW = it.width.toFloat() } + .graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) } + // 44dp to touch; the tab itself is the top 22. + .heightIn(min = 44.dp) + .semantics { contentDescription = settingsLabel } + .then( + if (downOpens) Modifier.focusProperties { canFocus = false } + else Modifier.focusRequester(lipFocus).onFocusChanged { lipFocused = it.isFocused }.controllerConfirm(onClick = lift), + ) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = lift), + ) { + Box(Modifier.glideBorder(lipFocused, RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp), LocalPalette.current.signal)) { + Handle(lipH) + } + } + } + } +} + +/** + * The sheet's handle, tucked under Play: "Settings" and a chevron that bobs up every so often, + * saying there is a drawer to pull up. Still with animations off. + */ +@Composable +private fun Handle(height: Dp) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + val still = Motion.scale == 0f + val bob = androidx.compose.animation.core.rememberInfiniteTransition(label = "handle") + // A quick lift and settle, then a rest: a nudge, not a pulse. + val lift by bob.animateFloat( + 0f, 0f, + androidx.compose.animation.core.infiniteRepeatable( + androidx.compose.animation.core.keyframes { + durationMillis = 2400 + 0f at 0 + 1f at 180 using androidx.compose.animation.core.FastOutSlowInEasing + -0.25f at 420 + 0f at 620 + }, + ), + label = "handleBob", + ) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(height).clip(shape).background(colors.surface) + .drawBorderSidesAndFoot(pal.line, 12.dp) + .padding(start = 14.dp, end = 10.dp), + ) { + Text(stringResource(R.string.drawer_page_settings), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1) + Icon( + Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant, + modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else -3.dp.toPx() * lift }, + ) + } +} + +/** 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, selected: Int, focus: List? = null, onPick: (Int) -> Unit) { + val pal = LocalPalette.current + val bounds = remember { mutableStateMapOf() } + val tint = rememberUpdatedState(pal.signal) + // Each chip its own requester: one moved from chip to chip under a focused chip lost the ring. + val chips = focus ?: remember(labels.size) { List(labels.size) { FocusRequester() } } + var within by remember { mutableStateOf(false) } + // LB / RB with a pad on the chips: focus goes with the selection. + LaunchedEffect(selected) { if (within) runCatching { chips[selected].requestFocus() } } + BoxWithConstraints(Modifier.fillMaxWidth().onFocusChanged { within = it.hasFocus }) { + val equal = maxWidth / labels.size.coerceAtLeast(1) >= 96.dp + val scroll = rememberScrollState() + // Room inside the scroll for the focus ring, which the scroll's clip would otherwise cut. + Box(if (equal) Modifier.fillMaxWidth() else Modifier.edgeFade(scroll).horizontalScroll(scroll).padding(3.dp)) { + 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() } + .focusRequester(chips[i]) + 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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index b348ff5ba..5aaf9f841 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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 @@ -152,293 +154,169 @@ 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 = + 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_STEAM to stringResource(R.string.mode_controller_steam), + SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4), + SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense), + 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_STEAM to stringResource(R.string.mode_controller_steam), - SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4), - SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense), - 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) SettingsGroup(stringResource(R.string.mode_client)) { - ToggleRow( - host, "mangoapp", stringResource(R.string.mode_mangoapp), - stringResource(R.string.mode_mangoapp_hint), - s.mangoapp, onChange = a.onMangoapp, - ) - 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) @@ -481,6 +359,96 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } + 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), + ) + } + } + SettingsGroup(stringResource(R.string.mode_client)) { + ToggleRow( + host, "mangoapp", stringResource(R.string.mode_mangoapp), + stringResource(R.string.mode_mangoapp_hint), + s.mangoapp, onChange = a.onMangoapp, + ) + 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 }, title = { Text(stringResource(R.string.mode_wifi_explain_title)) }, @@ -494,15 +462,66 @@ 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)) } }, +} + +/** + * The options that apply to every Proton - how Wine syncs, FEX's preset, Force SSBS and the games' + * environment - for the Components page's Proton layer. + */ +@Composable +internal fun ProtonOptions( + syncBackend: String, fexPreset: String, forceSsbs: Boolean, + onSyncBackend: (String) -> Unit, onFexPreset: (String) -> Unit, onForceSsbs: (Boolean) -> Unit, +) { + val host = rememberMenuHost() + 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), + ), + syncBackend, + ) { id -> host.open = null; 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) }, fexPreset, + note = stringResource(FexPreset.byId(fexPreset).detail), onPick = onFexPreset, ) + ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), forceSsbs, onChange = onForceSsbs) + GameEnvironmentRow() +} + +/** + * 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(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. */ diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt b/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt index a2b24b594..91a8eaa60 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt @@ -44,9 +44,38 @@ import kotlin.math.sin // Play (LaunchFlood): the control stretches out over the pane on loose springs, the far edges // first, and the page rises into it once it is covered. Back draws it down into the control again. -/** Where a page opened from: the control's bounds and corner radius, in px. */ -internal class Origin(val bounds: Rect, val corner: Float) { - fun translate(by: Offset) = Origin(bounds.translate(by), corner) +/** + * Where a page opened from: the control's bounds and corner radius, in px, and the rail item that + * was showing it ([owner]), so the page only draws back into it when that content returns. [art] + * is the control's picture for the tile (a drawable id, or an image Coil loads), if it has one. + */ +internal class Origin(val bounds: Rect, val corner: Float, val owner: String? = null, val art: Any? = null) { + fun translate(by: Offset) = Origin(bounds.translate(by), corner, owner, art) + fun ownedBy(key: String) = Origin(bounds, corner, key, art) +} + +/** How a page that opened from a control leaves. */ +internal enum class PageExit { + /** Back into the control it came from, which is on screen again. */ + Drain, + /** Out through a link into another section, which takes the page there itself. */ + Hop, + /** Away with the pane, as any page goes: the control it came from is not coming back. */ + Sink, +} + +/** Why a page is leaving: an ordinary change of page, or a link into another section. */ +internal enum class PageLeave { Ordinary, Link } + +/** + * The exit for a page opened from a control on [owner]'s content, leaving for [incoming]. It only + * draws back into the control when the content that control belongs to is what comes back; a page + * left for another rail item or another page drained into a control that was no longer there. + */ +internal fun pageExit(owner: String?, incoming: String, leave: PageLeave): PageExit = when { + owner != null && incoming == owner -> PageExit.Drain + leave == PageLeave.Link -> PageExit.Hop + else -> PageExit.Sink } internal object PageOrigin { @@ -70,6 +99,7 @@ internal const val PAGE_RETURN_MS = 900 * [content], a page that opened from the control at [from] (in this element's coordinates): the * control's tile stretching over it until covered, then the page rising in as the tile fades. * Once [leaving], the reverse: the tile comes back over the page and draws down into the control. + * A page that [sinks] instead goes with the pane and draws no tile at all. * The focus ring stays out of it until it is done. */ @Composable @@ -78,6 +108,7 @@ internal fun PageFlood( leaving: Boolean, /** What the tile shows instead of the control's fill: a game card's art, cropped to the tile as it grows. */ art: Painter? = null, + sinks: Boolean = false, content: @Composable () -> Unit, ) { val pal = LocalPalette.current @@ -87,7 +118,14 @@ internal fun PageFlood( val shown = remember { Animatable(0f) } val tile = remember { Animatable(1f) } var size by remember { mutableStateOf(Size.Zero) } - LaunchedEffect(size != Size.Zero, leaving) { + LaunchedEffect(size != Size.Zero, leaving, sinks) { + if (sinks) { + // Whatever the flood in was doing, the page is shown whole and leaves with the pane. + tile.snapTo(0f) + shown.snapTo(1f) + glide?.hidden = false + return@LaunchedEffect + } if (size == Size.Zero) return@LaunchedEffect glide?.hidden = true val b = from.bounds diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt deleted file mode 100644 index 3515f2180..000000000 --- a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.droiddeck.launcher.ui - -import com.droiddeck.launcher.R -import androidx.compose.ui.res.stringResource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean) - -@Composable -fun ProtonPage( - rows: List, - busyId: String?, - stage: String?, - percent: Int, - runtimeReady: Boolean, - sessionRunning: Boolean, - onInstall: (String) -> Unit, - onCancel: (String) -> Unit, - onRemove: (String) -> Unit, - onBack: () -> Unit, -) { - val host = rememberMenuHost() - val colors = MaterialTheme.colorScheme - SettingsPage( - host, - title = stringResource(R.string.setup_tool_protons), - eyebrow = stringResource(R.string.setup_title), - lede = stringResource(R.string.proton_lede), - onBack = onBack, - ) { - if (!runtimeReady) Text( - stringResource(R.string.content_needs_runtime), - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), - ) - if (sessionRunning) Text( - stringResource(R.string.proton_stop_first), - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), - ) - val compatible = stringResource(R.string.steam_compat_label) - Text( - stringResource(R.string.proton_compatible_note, compatible), - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), - ) - SettingsGroup(stringResource(R.string.proton_available)) { - for (row in rows) { - SettingsRow( - row.name, - when { - row.installed != null -> stringResource(R.string.proton_installed_compatible, row.installed, compatible) - row.queued -> stringResource(R.string.proton_queued) - else -> stringResource(R.string.proton_not_installed) - }, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - when { - row.installed != null -> SecondaryButton(stringResource(R.string.store_remove), enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) } - busyId != null -> SecondaryButton(if (busyId == row.id) stringResource(R.string.store_installing) else stringResource(R.string.store_install), enabled = false) {} - else -> SecondaryButton(stringResource(R.string.proton_install_now), enabled = runtimeReady && !sessionRunning) { onInstall(row.id) } - } - if (row.queued && row.installed == null && busyId == null) { - SecondaryButton(stringResource(R.string.proton_cancel_queued), enabled = !sessionRunning) { onCancel(row.id) } - } - } - } - if (busyId == row.id) Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) { - Text( - if (stage != null && percent >= 0) stringResource(R.string.store_busy_percent_short, stage, percent) else stage ?: stringResource(R.string.store_starting), - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp), - ) - if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) - else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) - } - } - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 3aeb4e2fb..c94818f0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -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() } + } } /** diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt b/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt new file mode 100644 index 000000000..4d01f20da --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt @@ -0,0 +1,237 @@ +package com.droiddeck.launcher.ui + +import android.os.SystemClock +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.sin + +// The moves the stores made first, for any section: one selection shape that glides along a row +// (or down a column) of choices, and a pressed control that squashes into a dot and flies home. + +/** Which way a [ChipGlide]'s choices run. */ +internal enum class GlideAxis { Horizontal, Vertical } + +/** + * The selection under a row of chips: [at] (in the row) of chip [index], in [tint]. A move to the + * next chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a + * beat later on its trail spring - and a hop past a chip pinches it to a drop, carries it and + * opens it. [axis] runs it down a column instead (the rail, a stack of layers). [corner] is the + * chips' own corner; null draws a capsule. [fill] and [outline] are the resting alphas. + */ +@Composable +internal fun BoxScope.ChipGlide( + index: Int, + at: Rect?, + tint: State, + axis: GlideAxis = GlideAxis.Horizontal, + corner: Dp? = null, + fill: Float = 0.16f, + outline: Float = 0.7f, +) { + val density = androidx.compose.ui.platform.LocalDensity.current + // The two edges along the axis (left and right, or top and bottom), and the two across it. + val start = remember { Animatable(Float.NaN) } + val end = remember { Animatable(0f) } + val pinch = remember { Animatable(0f) } + val crossStart = remember { mutableStateOf(0f) } + val crossEnd = remember { mutableStateOf(0f) } + val was = remember { intArrayOf(-1) } + val vertical = axis == GlideAxis.Vertical + LaunchedEffect(index, at) { + val b = at ?: return@LaunchedEffect + val s = if (vertical) b.top else b.left + val e = if (vertical) b.bottom else b.right + crossStart.value = if (vertical) b.left else b.top + crossEnd.value = if (vertical) b.right else b.bottom + val from = was[0] + was[0] = index + if (start.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) { + start.snapTo(s); end.snapTo(e); pinch.snapTo(0f) + return@LaunchedEffect + } + coroutineScope { + if (abs(index - from) > 1) { + // Too far to stretch: pinch into a drop where it is, carry it over, open it there. + pinch.animateTo(1f, Motion.tw(90)) + val l = launch { start.animateTo(s, Motion.sp(0.7f, 380f)) } + val r = launch { end.animateTo(e, Motion.sp(0.7f, 380f)) } + l.join(); r.join() + pinch.animateTo(0f, Motion.sp(0.6f, 500f)) + } else { + val forward = index > from + val (lead, trail) = if (forward) end to start else start to end + val leadTo = if (forward) e else s + val trailTo = if (forward) s else e + launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) } + launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) } + } + } + } + Canvas(Modifier.matchParentSize()) { + if (start.value.isNaN() || crossEnd.value <= crossStart.value) return@Canvas + val p = pinch.value + val shape = glideShape(start.value, end.value, crossStart.value, crossEnd.value, p, with(density) { 6.dp.toPx() }, vertical) + val at0 = shape.topLeft + val size = shape.size + val round = minOf(size.width, size.height) / 2f + val r = CornerRadius(corner?.let { lerp(minOf(with(density) { it.toPx() }, round), round, p) } ?: round) + // A drop in flight is solid; on a chip it is the chip's tinted fill and outline. + drawRoundRect(tint.value.copy(alpha = lerp(fill, 0.9f, p)), at0, size, r) + if (outline > 0f) { + // Inside the chip's edge: a stroke on it would lose its outer half to a scrolling row's clip. + val w = with(density) { 1.dp.toPx() } + val inset = w / 2f + drawRoundRect( + tint.value.copy(alpha = outline * (1f - p)), at0 + Offset(inset, inset), Size(size.width - w, size.height - w), + CornerRadius((r.x - inset).coerceAtLeast(0f)), style = Stroke(w), + ) + } + } +} + +/** + * Where a glide's shape is: its edges along the axis at [start] and [end] (either order), across + * it from [crossStart] to [crossEnd], pinched [pinch] of the way (0 to 1) into a drop [drop] each + * side of its centre. [vertical] runs the axis down instead of across. + */ +internal fun glideShape(start: Float, end: Float, crossStart: Float, crossEnd: Float, pinch: Float, drop: Float, vertical: Boolean): Rect { + val a0 = minOf(start, end) + val a1 = maxOf(start, end) + val ca = (a0 + a1) / 2f + val cc = (crossStart + crossEnd) / 2f + val s0 = lerp(a0, ca - drop, pinch) + val s1 = lerp(a1, ca + drop, pinch) + val c0 = lerp(crossStart, cc - drop, pinch) + val c1 = lerp(crossEnd, cc + drop, pinch) + return if (vertical) Rect(c0, s0, c1, s1) else Rect(s0, c0, s1, c1) +} + +/** + * A dot on its way somewhere: from the control at [from] (root px) to the point [to] gives (root + * px, asked each frame, so a target that moves is followed). [color] null is the palette's signal + * blue. [onHalf] runs as it passes the top of its arc, [onLand] once it lands. + */ +internal class Flight( + val from: Rect, + val to: Density.() -> Offset?, + val color: Color? = null, + val onHalf: () -> Unit = {}, + val onLand: () -> Unit = {}, +) { + val id = SystemClock.uptimeMillis() * 1000 + (next++ % 1000) + + private companion object { var next = 0L } +} + +/** Dots flying anywhere outside the Stores page (which keeps its own): a hop, a swap, a toggle. */ +internal object Flights { + val all = mutableStateListOf() + + /** Sends [f], or with animations off just runs its ends. */ + fun fly(f: Flight) { + if (Motion.scale == 0f) { f.onHalf(); f.onLand(); return } + all += f + } +} + +/** [Flights] drawn over everything, in the space of an element at [origin] (root px). */ +@Composable +internal fun FlightsLayer(origin: Offset) { + for (f in Flights.all.toList()) key(f.id) { FlightDot(f, origin) { Flights.all.remove(f) } } +} + +/** + * One dot: the control squashes into a 14dp dot (width first, a little bloop), which then flies + * on an arc to its target, stretched along its path like the focus ring's drop. + */ +@Composable +internal fun FlightDot(f: Flight, origin: Offset, onDone: () -> Unit) { + val pal = LocalPalette.current + val color = f.color ?: pal.signal + val t = remember { Animatable(0f) } + LaunchedEffect(Unit) { + t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing)) + coroutineScope { + launch { delay(Motion.ms(150).toLong()); f.onHalf() } + t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing)) + } + f.onLand() + onDone() + } + Canvas(Modifier.fillMaxSize()) { + val r = 7.dp.toPx() + val from = f.from.translate(-origin) + val a = from.center + val b = (f.to(this) ?: return@Canvas) - origin + val v = t.value + if (v < 1f) { + // Squash: the width goes first, then the height, into the dot; a bloop at the end. + val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f)) + val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f)) + val k = 1f - 0.15f * sin(PI * v).toFloat() + drawRoundRect(color, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2)) + } else { + val p = v - 1f + val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx()) + val q = 1f - p + val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y) + val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y)) + val stretch = sin(PI * p).toFloat() + val angle = atan2(d.y, d.x) * 180f / PI.toFloat() + rotate(angle, pos) { + val w = 2 * r * (1f + 0.5f * stretch) + val h = 2 * r * (1f - 0.25f * stretch) + drawOval(color, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h)) + } + } + } +} + +/** + * A row that scrolls sideways fades out at an edge it has more past, instead of slicing a pill or + * a card through: the content's own alpha, so whatever is behind the row shows through. + */ +internal fun Modifier.edgeFade(scroll: androidx.compose.foundation.ScrollState, width: Dp = 28.dp): Modifier = + this.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val w = width.toPx().coerceAtMost(size.width / 3f) + if (scroll.value > 0) drawRect( + androidx.compose.ui.graphics.Brush.horizontalGradient(0f to Color.Transparent, 1f to Color.Black, startX = 0f, endX = w), + size = Size(w, size.height), blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, + ) + if (scroll.value < scroll.maxValue) drawRect( + androidx.compose.ui.graphics.Brush.horizontalGradient(0f to Color.Black, 1f to Color.Transparent, startX = size.width - w, endX = size.width), + topLeft = Offset(size.width - w, 0f), size = Size(w, size.height), blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, + ) + } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SheetRows.kt b/app/src/main/java/com/droiddeck/launcher/ui/SheetRows.kt new file mode 100644 index 000000000..ac6364a2a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SheetRows.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R + +// 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. + +/** 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) + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index c5e1a1cb7..5b65d2202 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.inset import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import coil.compose.rememberAsyncImagePainter @@ -82,41 +83,52 @@ 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 { it.lastPlayed }.thenBy { it.gameId }) } val narrow = LocalNarrowPane.current - val colors = MaterialTheme.colorScheme // Where the words and buttons sit, for the empty library's wandering mark to keep off. var words by remember { mutableStateOf(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, + 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 { + // The mark wanders over this fade, so it sinks with the wall: drawn a little oversized, + // it still reaches the pane's edges at the sink's 0.95. + games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } } + games.size < WALL_MIN_GAMES -> CapsuleFan(games) + else -> CapsuleWall(games) } - } - 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)) { + }, + scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() }, + 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. */ @Composable -private fun WallFade() { +internal fun WallFade() { val colors = MaterialTheme.colorScheme Box( Modifier.fillMaxSize().background( @@ -147,8 +159,12 @@ internal fun CapsuleWall( TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) } } -/** One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. */ -internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null) +/** + * One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. A + * tile that is an app rather than a cover carries an [icon] instead (a drawable id or an image), + * drawn whole at the tile's centre, and a window's [bar] along its foot. + */ +internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null) /** * [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] describes number i @@ -161,25 +177,28 @@ internal fun TiltedWall( count: Int, driftMs: Int, modifier: Modifier = Modifier, + /** One tile's size, the gap between tiles and their corner: portrait capsules unless a wall says otherwise. */ + capW: androidx.compose.ui.unit.Dp = 112.dp, + capH: androidx.compose.ui.unit.Dp = 168.dp, + gap: androidx.compose.ui.unit.Dp = 14.dp, + corner: androidx.compose.ui.unit.Dp = 10.dp, capsule: (index: Int) -> WallCapsule, ) { - val capW = 112.dp - val capH = 168.dp - val gap = 14.dp // Animations off in the system settings: a still wall. val still = Motion.scale == 0f // The wall moves a few pixels a step. Stepped on frames at most every 15 ms it looks the same as - // stepped on every frame, which on a 120 Hz screen drew it twice as often. + // stepped on every frame, which on a 120 Hz screen drew it twice as often. The phase advances by + // the time since the last step over the period now, so a period that changes (Flathub's wall + // slowing while it sets up) changes the speed without the wall jumping. val drift = remember { mutableFloatStateOf(0f) } - if (!still) LaunchedEffect(driftMs) { - val period = driftMs * 1_000_000.0 * Motion.scale - var start = 0L + val period = androidx.compose.runtime.rememberUpdatedState(driftMs) + if (!still) LaunchedEffect(Unit) { var shown = 0L while (true) withFrameNanos { now -> - if (start == 0L) start = now - if (shown == 0L || now - shown >= DRIFT_STEP_NS) { + if (shown == 0L) shown = now + else if (now - shown >= DRIFT_STEP_NS) { + drift.floatValue = ((drift.floatValue + (now - shown) / (period.value * 1_000_000.0 * Motion.scale)) % 1.0).toFloat() shown = now - drift.floatValue = ((now - start) % period / period).toFloat() } } } @@ -196,14 +215,15 @@ internal fun TiltedWall( val run = (capH + gap) * perRun val cap = with(density) { Size(capW.toPx(), capH.toPx()) } val step = with(density) { (capH + gap).toPx() } - val radius = with(density) { 10.dp.toPx() } + val radius = with(density) { corner.toPx() } val line = with(density) { 1.dp.toPx() } + val barH = with(density) { 12.dp.toPx() } val indexOf = { c: Int, i: Int -> if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count } val shown = remember(count, columns, perRun) { (0 until columns).flatMap { c -> (0 until perRun).map { indexOf(c, it) } }.distinct() } val looks = HashMap>(shown.size * 2) for (index in shown) key(index) { val look = capsule(index) - looks[index] = look to look.art?.let { rememberCapsuleArt(it, cap, radius) } + looks[index] = look to (look.art?.let { rememberCapsuleArt(it, cap, radius) } ?: look.icon?.let { rememberIcon(it) }) } Row( horizontalArrangement = Arrangement.spacedBy(gap), @@ -224,7 +244,7 @@ internal fun TiltedWall( for (i in 0 until perRun * 2) { val (look, art) = looks[indexOf(c, i)] ?: continue val top = i * step - inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line) } + inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line, barH) } } }, ) @@ -247,9 +267,20 @@ private fun rememberCapsuleArt(model: Any, size: Size, radius: Float): Painter { ) } -private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float) { +/** An app's icon for a wall tile: a bundled drawable, or an image Coil loads. */ +@Composable +private fun rememberIcon(model: Any): Painter = + if (model is Int) androidx.compose.ui.res.painterResource(model) else rememberAsyncImagePainter(model) + +private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float, barH: Float = 0f) { drawRoundRect(look.ground, size = size, cornerRadius = CornerRadius(radius)) - if (art != null) with(art) { draw(size) } + look.bar?.let { drawRect(it, Offset(0f, size.height - barH), Size(size.width, barH)) } + if (art != null && look.icon != null) { + // An app's icon whole at the tile's centre (above a window's bar), not cropped over it. + val side = minOf(size.width, size.height - (if (look.bar != null) barH else 0f)) * 0.5f + val cy = (size.height - (if (look.bar != null) barH else 0f)) / 2f + translate((size.width - side) / 2f, cy - side / 2f) { with(art) { draw(Size(side, side)) } } + } else if (art != null) with(art) { draw(size) } look.edge?.let { drawRoundRect(it, Offset(line / 2, line / 2), Size(size.width - line, size.height - line), CornerRadius(radius - line / 2), style = Stroke(line)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt b/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt index 7ab5cbcc0..624176b51 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt @@ -184,6 +184,10 @@ internal fun StepOut( BoxWithConstraints(Modifier.fillMaxSize()) { val hostWidth = constraints.maxWidth.toFloat() + val hostHeight = constraints.maxHeight.toFloat() + // No room under the control (a row near the foot of the screen): it steps out upward + // instead, the same outline mirrored about the control. + val up = measured?.let { pill.bottom + pull + it.height > hostHeight && pill.top - pull - it.height >= 0f } ?: false val room = if (side == StepSide.Left) pill.right else hostWidth - pill.left val maxW = with(density) { maxWidth.toPx() }.coerceAtMost(room).coerceAtLeast(pill.width) LaunchedEffect(open, measured) { @@ -246,6 +250,7 @@ internal fun StepOut( if (!grown) return@Canvas if (side == StepSide.Left) stepPath(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner) else stepPathRight(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner) + if (up) path.transform(Matrix().apply { this[1, 1] = -1f; this[3, 1] = pill.top + pill.bottom }) drawPath(path, lerp(hotFill, restFill, tint.value)) drawPath(path, accent.copy(alpha = lerpF(0.9f, 0.55f, tint.value)), style = Stroke(rim)) } @@ -258,7 +263,7 @@ internal fun StepOut( val x = if (side == StepSide.Left) pill.right - (measured?.width?.toFloat() ?: maxW) else pill.left Column( modifier = Modifier - .offset { IntOffset(x.roundToInt(), (pill.bottom + pull).roundToInt()) } + .offset { IntOffset(x.roundToInt(), (if (up) pill.top - pull - (measured?.height ?: 0) else pill.bottom + pull).roundToInt()) } .widthIn(min = with(density) { pill.width.toDp() }, max = with(density) { maxW.toDp() }) .onSizeChanged { if (measured != it) measured = it } .controllerBack(onDismiss) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Steps.kt b/app/src/main/java/com/droiddeck/launcher/ui/Steps.kt new file mode 100644 index 000000000..d0b9369a3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/Steps.kt @@ -0,0 +1,55 @@ +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(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 + // Back (B, or the system's) folds what is stepped out, before it reaches the page under it. + androidx.activity.compose.BackHandler(enabled = Steps.open) { Steps.fold() } + 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, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt index c35a9beda..410d44e99 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -19,6 +19,13 @@ import com.droiddeck.launcher.stores.Store // Where a game came from, as a small coloured chip: Steam, one of the stores, or an added folder. +/** Flathub's own accent, as each store has one, and the ink that reads on it. */ +internal val FlathubTeal = Color(0xFF3FB8AF) +internal val FlathubInk = Color(0xFF04211F) + +/** The Store rail section's source id, for its colours. */ +internal const val FLATHUB = "flathub" + /** The chip's colours per source: a tinted ground and a readable ink on it, one pair per store. */ internal class SourceColours(val fill: Color, val ink: Color, val dot: Color) @@ -27,6 +34,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) { Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD)) Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) + FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index bf334e2d2..87eaaa92c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -1,283 +1,429 @@ package com.droiddeck.launcher.ui -import com.droiddeck.launcher.R -import androidx.compose.ui.res.stringResource +import android.os.Build import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState 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.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight +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.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.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.Verified +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage +import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.store.FlathubApi import com.droiddeck.launcher.store.StoreState +import kotlinx.coroutines.delay // The Store rail section: Flathub's apps for this device's architecture, installed into the -// Linux runtime with Flatpak, and the ones already installed. +// Linux runtime with Flatpak, and the ones already installed - in the stores' style, in Flathub's +// teal. Until Flatpak is set up it is the stores' sign-in hero: a wall of real app icons, one word, +// one button that becomes its own progress. Then a chip row, shelves of square cards that flood +// open into an app's page, and installs that fly home to the Installed chip. -private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed) +private const val MODE_DISCOVER = "discover" +private const val MODE_INSTALLED = "installed" +private const val MODE_UPDATES = "updates" + +/** One app as a card shows it: Flathub's summary or what Flatpak says is installed. */ +private class CardApp(val id: String, val name: String, val icon: Any?, val summary: String? = null, val verified: Boolean = false) + +/** Where the Installed chip is, for an install's dot to land in, and how many have landed. */ +private object FlathubMotion { + var installedChip: Rect? = null + var landed by mutableIntStateOf(0) +} @Composable internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { val ctx = LocalContext.current - val narrow = LocalNarrowPane.current - val padH = if (narrow) 16.dp else 22.dp - val padV = if (narrow) 12.dp else 18.dp - var tab by rememberSaveable { mutableStateOf(0) } - var openApp by rememberSaveable { mutableStateOf(null) } - var query by rememberSaveable { mutableStateOf("") } - var category by rememberSaveable { mutableStateOf(null) } LaunchedEffect(s.ready) { StoreState.refresh(ctx) } LaunchedEffect(Unit) { StoreState.loadSections() } - // A pad's focus sits on something the page is about to replace - the tile that opens an app, - // a tab's contents - and would be lost with it. Each move says where focus goes next. - val ff = LocalFrontFocus.current - val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current - val tabFocus = remember { List(TABS.size) { androidx.compose.ui.focus.FocusRequester() } } - var focusMove by remember { mutableStateOf?>(null) } - var lastApp by remember { mutableStateOf(null) } - fun closeApp() { lastApp = openApp; openApp = null; focusMove = "tile" to (focusMove?.second ?: 0) + 1 } - fun switchTab(to: Int) { tab = to; focusMove = "tab" to (focusMove?.second ?: 0) + 1 } - BackHandler(enabled = openApp != null) { closeApp() } - val scroll = rememberScrollState() - LaunchedEffect(openApp, tab) { scroll.scrollTo(0) } - LaunchedEffect(focusMove, openApp) { - if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect - val target = when { - openApp != null -> ff?.primary - focusMove?.first == "tile" -> lastApp?.let { ff?.items?.get("tile:store:$it") } ?: tabFocus[tab] - focusMove?.first == "tab" -> tabFocus[tab] - else -> null - } ?: return@LaunchedEffect - var landed = false - focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } } - } - Column( - modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV) - .bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) }, - onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }), - ) { - val id = openApp - if (id != null) { - AppDetail(s, a, id) { closeApp() } - return@Column + FlathubTheme { + // The storefront takes over from the hero once Flatpak is ready: the setup rail rings + // once, then the hero sinks away and the storefront rises. + var front by remember { mutableStateOf(StoreState.ready && s.ready) } + var ring by remember { mutableIntStateOf(0) } + LaunchedEffect(StoreState.ready, s.ready) { + val ready = StoreState.ready && s.ready + if (ready && !front) { + ring++ + delay(Motion.ms(450).toLong()) + front = true + } else if (!ready) front = false } - Rise(0) { - PageHeader(stringResource(R.string.store_title)) { - Chip(stringResource(R.string.store_chip_flathub), ok = false) - if (StoreState.ready) Chip(stringResource(R.string.store_chip_ready), ok = true) - } - } - if (!s.ready) { - Rise(1) { Note(stringResource(R.string.store_needs_runtime)) } - return@Column - } - if (!StoreState.ready) Rise(1) { SetupCard() } - Rise(2) { - TabStrip( - TABS.mapIndexed { i, id -> stringResource(id).let { t -> if (i == 2 && StoreState.installed.isNotEmpty()) stringResource(R.string.store_tab_count, t, StoreState.installed.size) else t } }, - tab, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus, - ) - } - BusyBar() - when (tab) { - 0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) }) - 1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it }) - else -> Installed(a, onOpen = { openApp = it }) + AnimatedContent( + front, + modifier = modifier, + transitionSpec = { + EnterTransition.None.togetherWith(fadeOut(Motion.tw(320)) + scaleOut(Motion.tw(320), targetScale = 0.95f)) + }, + label = "flathub", + ) { ready -> + if (ready) Storefront(s, a) else FlathubHero(s, ring) } } } -/** Flatpak is not in the runtime yet: one button puts it there. */ +/** + * Flatpak is not set up yet: the stores' sign-in shape in teal. A tilted wall of the popular apps' + * icons (Flathub's public pages need no Flatpak) drifts behind, fading toward the words; one big + * button sets it up, and stretches into its own progress while it does. + */ @Composable -private fun SetupCard() { +private fun FlathubHero(s: FrontEndState, ring: Int) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val pal = LocalPalette.current + val narrow = LocalNarrowPane.current + val icons = remember(StoreState.sections.values.toList()) { + StoreState.sections.values.flatten().mapNotNull { it.icon }.distinct().take(48).takeIf { it.size >= 8 }.orEmpty() + } val busy = StoreState.busy == "setup" - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp), - ) { - Text(stringResource(R.string.store_setup_title), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Text( - stringResource(R.string.store_setup_text), - fontSize = 14.sp, color = colors.onSurfaceVariant, - ) - Actions { - PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) } + BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp)) { + val shape = RoundedCornerShape(20.dp) + Box(Modifier.fillMaxWidth().height(maxHeight.coerceAtLeast(320.dp)).clip(shape).background(colors.background).border(1.dp, pal.line, shape)) { + // Slower while it sets up, as the empty wall drifts. + androidx.compose.animation.Crossfade(icons, animationSpec = Motion.tw(600), label = "flathubWall") { tiles -> + val ground = Brush.linearGradient(listOf(Color(0xFF0B2E2B), Color(0xFF0B2E2B))) + val edge = FlathubTeal.copy(alpha = 0.18f) + TiltedWall(tiles.size, if (tiles.isEmpty() || busy) 75_000 else 50_000, capW = 68.dp, capH = 68.dp, gap = 14.dp, corner = 17.dp) { i -> + WallCapsule(ground, edge = edge, icon = tiles.getOrNull(i)) + } + } + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0f to colors.background.copy(alpha = 0.97f), 0.45f to colors.background.copy(alpha = 0.9f), + 0.78f to colors.background.copy(alpha = 0.35f), 1f to colors.background.copy(alpha = 0.15f), + ), + ), + ) + Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.5f to Color.Transparent, 1f to colors.background.copy(alpha = 0.92f)))) + Box( + Modifier.fillMaxSize().drawBehind { + drawRect(Brush.radialGradient(listOf(FlathubTeal.copy(alpha = 0.22f), Color.Transparent), center = Offset(size.width * 0.16f, size.height * 0.9f), radius = size.height * 0.75f)) + }, + ) + BoxWithConstraints(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(start = if (narrow) 20.dp else 40.dp, bottom = if (narrow) 20.dp else 36.dp, end = 20.dp)) { + val full = maxWidth + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Rise(1) { + Text(stringResource(R.string.flathub_eyebrow), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp, color = FlathubTeal) + } + Rise(2) { + Text( + stringResource(R.string.flathub_name), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp, + fontWeight = FontWeight.Black, color = colors.onBackground, + ) + } + Rise(3) { Text(stringResource(R.string.flathub_line), fontSize = 15.sp, color = colors.onSurfaceVariant) } + Spacer(Modifier.height(8.dp)) + Rise(4) { SetupButton(s, busy, full, ring) { StoreState.setup(ctx) } } + } + } } } } -/** The running install, update or setup, whichever page it was started from. */ +/** + * Set up Flathub, and its own progress: while it sets up the button springs out to the full width + * and fills in teal from the left with how far it is, the stage and the figure inside it. Without + * a runtime it says what is missing instead. + */ @Composable -private fun BusyBar() { +private fun SetupButton(s: FrontEndState, busy: Boolean, full: Dp, ring: Int, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme - val busy = StoreState.busy ?: return - val name = StoreState.installed.firstOrNull { it.id == busy }?.name ?: StoreState.details[busy]?.name - ?: when (busy) { "setup" -> stringResource(R.string.store_flatpak); "update-all" -> stringResource(R.string.store_updates); else -> busy.substringAfterLast('.') } - Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) { - val stage = StoreState.stage - Text( - if (stage != null && StoreState.percent >= 0) stringResource(R.string.store_busy_percent, name, stage, StoreState.percent) else stringResource(R.string.store_busy, name, stage ?: stringResource(R.string.store_starting)), - fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(bottom = 6.dp), - ) - if (StoreState.percent >= 0) LinearProgressIndicator(progress = { StoreState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) - else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) - } -} - -@Composable -private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) { - Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) } - Rise(3) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) { - FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } } + val density = LocalDensity.current + val ff = LocalFrontFocus.current + val inputMode = LocalInputModeManager.current + var restW by remember { mutableStateOf(0.dp) } + val grow by animateFloatAsState(if (busy) 1f else 0f, Motion.sp(0.7f, 380f), label = "setupGrow") + val fill by animateFloatAsState((StoreState.percent.coerceAtLeast(0) / 100f), Motion.tw(300), label = "setupFill") + if (!busy && grow < 0.01f) { + Box(Modifier.onSizeChanged { restW = with(density) { it.width.toDp() } }) { + PrimaryButton( + if (s.ready) stringResource(R.string.flathub_setup) else stringResource(R.string.store_needs_runtime), + enabled = s.ready && StoreState.busy == null, main = true, large = true, icon = Icons.Filled.Download, onClick = onClick, + ) } - } - if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) { - Rise(4) { Note(stringResource(R.string.store_unreachable)) } - Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } } return } - StoreState.SECTIONS.forEachIndexed { i, (key, title) -> - val apps = StoreState.sections[key] - Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) } - if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) } + // A pad keeps its place on the rail that took the button's. + if (ff != null) LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { ff.primary } } + val width = androidx.compose.ui.unit.lerp(restW.coerceAtLeast(120.dp), full, grow) + val shape = RoundedCornerShape(12.dp) + val shimmer = rememberInfiniteTransition(label = "setupShimmer") + val sweep by shimmer.animateFloat(-0.3f, 1.3f, infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart), label = "sweep") + val stage = StoreState.stage ?: stringResource(R.string.store_starting) + val label = if (StoreState.percent >= 0) "$stage · ${StoreState.percent}%" else stage + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.width(width).heightIn(min = 56.dp) + .then(if (ff != null) Modifier.focusRequester(ff.primary) else Modifier) + .pulseRing(ring, FlathubTeal) + .clip(shape).background(colors.surfaceVariant).border(1.dp, FlathubTeal.copy(alpha = 0.5f), shape) + .drawBehind { + if (StoreState.percent >= 0) drawRect(FlathubTeal, size = size.copy(width = size.width * fill)) + else if (Motion.scale != 0f) { + val w = size.width * 0.3f + drawRect(Brush.horizontalGradient(listOf(Color.Transparent, FlathubTeal.copy(alpha = 0.55f), Color.Transparent), startX = size.width * sweep - w, endX = size.width * sweep + w)) + } + } + .focusable() + .semantics { contentDescription = label } + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text(label, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = if (StoreState.percent >= 50) FlathubInk else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) } } +// ---- storefront ------------------------------------------------------------------------------------ + @Composable -private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) { - val view = androidx.compose.ui.platform.LocalView.current - val run = { - // The field is an Android EditText; Done leaves its keyboard over the results otherwise. - (view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager) - ?.hideSoftInputFromWindow(view.windowToken, 0) +private fun Storefront(s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current + val narrow = LocalNarrowPane.current + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val padH = if (narrow) 14.dp else 22.dp + var mode by rememberSaveable { mutableStateOf(MODE_DISCOVER) } + var query by rememberSaveable { mutableStateOf("") } + var category by rememberSaveable { mutableStateOf(null) } + var openApp by rememberSaveable { mutableStateOf(null) } + val updates = StoreState.updates + if (mode == MODE_UPDATES && updates.isEmpty()) mode = MODE_INSTALLED + // Once a session is plenty: the Updates chip shows itself when there is something to update. + LaunchedEffect(StoreState.ready) { if (StoreState.ready && !StoreState.updatesChecked && !StoreState.checkingUpdates) StoreState.checkUpdates(ctx) } + // A search runs a beat after typing stops, or as a category is picked. + LaunchedEffect(query, category) { + if (query.isBlank() && category == null) { StoreState.clearSearch(); return@LaunchedEffect } + delay(350) StoreState.search(query.trim(), category) } - Rise(3) { - Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { - AdbTextField( - value = query, onValueChange = onQuery, label = stringResource(R.string.store_search_label), keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, placeholder = stringResource(R.string.store_search_placeholder), modifier = Modifier.weight(1f), onDone = run, - ) - PrimaryButton(stringResource(R.string.store_search), main = true, onClick = run) + val ff = LocalFrontFocus.current + val inputMode = LocalInputModeManager.current + // Cards, where they are, for the page that opens from one. + val cards = remember { HashMap() } + var viewportAt by remember { mutableStateOf(Offset.Zero) } + var detail by remember { mutableStateOf(null) } + var leaving by remember { mutableStateOf(false) } + var detailFrom by remember { mutableStateOf(null) } + var lastApp by remember { mutableStateOf(null) } + LaunchedEffect(openApp) { + val id = openApp + if (id != null) { + detailFrom = cards[id]?.let { Origin(it.translate(-viewportAt), 16f * density.density) } + leaving = false + detail = id + if (inputMode.inputMode == InputMode.Keyboard && ff != null) { delay(Motion.ms(300).toLong()); focusSoon { ff.primary } } + } else if (detail != null) { + lastApp = detail + if (detailFrom != null) { leaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) } + detail = null; leaving = false; detailFrom = null } } - Rise(4) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) { - PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) } - FlathubApi.categories.forEach { c -> - PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) } + // Back on the grid: focus goes to the card the page came from. + LaunchedEffect(detail) { + if (detail != null || inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect + val id = lastApp ?: return@LaunchedEffect + ff.items["tile:store:$id"]?.let { req -> focusSoon { req } } + } + BackHandler(enabled = openApp != null) { openApp = null } + // An app's card on Desktop asked for its page. + LaunchedEffect(StoreState.openRequest) { StoreState.openRequest?.let { openApp = it; StoreState.openRequest = null } } + val covering = detail != null && !leaving + val sink by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "flathubSink") + val fade by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "flathubFade") + val modes = buildList { add(MODE_DISCOVER); add(MODE_INSTALLED); if (updates.isNotEmpty()) add(MODE_UPDATES) } + // LB / RB with a pad: focus goes to the new mode's chip; the cards it was on leave with the old mode. + val seenMode = remember { arrayOf(mode) } + LaunchedEffect(mode) { + if (seenMode[0] == mode) return@LaunchedEffect + seenMode[0] = mode + if (inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect + ff.items["flathub:$mode"]?.let { req -> focusSoon { req } } + } + + Column( + Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) + .bumpers( + onPrevious = { if (openApp == null) mode = modes[(modes.indexOf(mode) + modes.size - 1) % modes.size] }, + onNext = { if (openApp == null) mode = modes[(modes.indexOf(mode) + 1) % modes.size] }, + ), + ) { + Rise(0) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + // A page of Desktop's: the way back to it, for a finger (B does the same). + val show = LocalShowSection.current + BackLink(stringResource(R.string.rail_desktop), compact = true) { show("desktop") } + Box(Modifier.weight(1f)) { FlathubChips(mode, modes, StoreState.installed.size, updates.size) { mode = it } } } } - } - val results = StoreState.searchResults - when { - StoreState.searching -> Rise(5) { SectionTitle(stringResource(R.string.store_searching), null) } - results == null -> Rise(5) { Note(stringResource(R.string.store_search_hint)) } - results.isEmpty() -> Rise(5) { Note(stringResource(R.string.store_no_results)) } - else -> { - Rise(5) { SectionTitle(stringResource(R.string.store_results), results.size.toString()) } - Rise(6) { AppGrid(results, first = true, onOpen = onOpen) } + Spacer(Modifier.height(8.dp)) + Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().onGloballyPositioned { viewportAt = it.positionInRoot() }) { + Column( + Modifier.fillMaxSize().graphicsLayer { scaleX = sink; scaleY = sink; alpha = fade }.verticalScroll(rememberScrollState()).padding(bottom = 16.dp), + ) { + val open: (String) -> Unit = { openApp = it } + when (mode) { + MODE_DISCOVER -> Discover(query, category, { query = it }, { category = if (category == it) null else it }, cards, open) + MODE_INSTALLED -> InstalledMode(cards, open) + else -> UpdatesMode(cards, open) + } + } + detail?.let { id -> + val from = detailFrom + val icon = StoreState.details[id]?.icon ?: StoreState.sections.values.flatten().firstOrNull { it.id == id }?.icon ?: FlathubApi.iconUrl(id) + val body: @Composable () -> Unit = { + Column( + Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } } + .verticalScroll(rememberScrollState()).padding(bottom = 16.dp).focusGroup(), + ) { AppDetail(s, a, id) { openApp = null } } + } + if (from == null) body() + else { + // The card's own icon, cropped over the tile as it grows: the page arrives in the app's colour. + val art = coil.compose.rememberAsyncImagePainter( + remember(icon) { coil.request.ImageRequest.Builder(ctx).data(icon).size(256, 256).build() }, + ) + PageFlood(from, leaving, art) { body() } + } + } } } } +/** + * Discover, Installed (with its count) and Updates (when there are any): one selection glides under + * them in teal; the Installed count pops as an install's dot lands in it. + */ @Composable -private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { - val ctx = LocalContext.current - val colors = MaterialTheme.colorScheme +private fun FlathubChips(mode: String, modes: List, installed: Int, updates: Int, onPick: (String) -> Unit) { val pal = LocalPalette.current - val apps = StoreState.installed - Rise(3) { - Actions { - SecondaryButton(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) { - StoreState.checkUpdates(ctx) - } - if (StoreState.updates.isNotEmpty()) PrimaryButton(stringResource(R.string.store_update_all, StoreState.updates.size), enabled = StoreState.busy == null) { - StoreState.update(ctx, null, ctx.getString(R.string.store_updates)) - } else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip(stringResource(R.string.store_all_current), ok = true) - } - } - if (apps.isEmpty()) { - Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) stringResource(R.string.store_no_apps) else stringResource(R.string.store_setup_first_note)) } } - return - } - Rise(4) { SectionTitle(stringResource(R.string.store_tab_installed), apps.size.toString()) } - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - apps.forEachIndexed { i, app -> - key(app.id) { - Rise(5 + i.coerceAtMost(6)) { + val colors = MaterialTheme.colorScheme + val bounds = remember { mutableStateMapOf() } + val tint = rememberUpdatedState(pal.signal) + Box(Modifier.fillMaxWidth()) { + ChipGlide(modes.indexOf(mode), bounds[mode], tint) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + for (m in modes) { + key(m) { + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + val on = m == mode Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + modifier = Modifier.weight(1f).paneItem("flathub:$m") + .onGloballyPositioned { + bounds[m] = it.boundsInParent() + if (m == MODE_INSTALLED) FlathubMotion.installedChip = it.boundsInRoot() + } + .heightIn(min = 44.dp).clip(shape) + .background(if (on) Color.Transparent else colors.surface) + .glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab) { onPick(m) } + .controllerConfirm { onPick(m) } + .padding(horizontal = 12.dp, vertical = 8.dp), ) { - AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44) - Column(modifier = Modifier.weight(1f)) { - Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - (app.summary ?: app.id).let { if (app.id in StoreState.updates) stringResource(R.string.store_update_available_prefix, it) else it }, - fontSize = 13.sp, color = if (app.id in StoreState.updates) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - } - PrimaryButton(stringResource(R.string.store_open), compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) } - if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) } - SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) } + Text( + stringResource(when (m) { MODE_DISCOVER -> R.string.store_tab_discover; MODE_INSTALLED -> R.string.store_tab_installed; else -> R.string.store_updates }), + fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, + ) + val count = when (m) { MODE_INSTALLED -> installed; MODE_UPDATES -> updates; else -> -1 } + if (count >= 0) Text( + count.toString(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1, + modifier = Modifier.then(if (m == MODE_INSTALLED) Modifier.landingPop(FlathubMotion.landed) else Modifier) + .clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 6.dp, vertical = 1.dp), + ) } } } @@ -285,8 +431,202 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { } } -private fun s_busyFor(id: String) = StoreState.busy == id +/** + * The card width for a width: room for a name on two lines and a two-line summary under the art, + * so a long name wraps instead of being cut to a few letters. + */ +private fun cardSize(width: Dp): Dp = when { + width >= 700.dp -> 164.dp + width >= 480.dp -> 148.dp + else -> 132.dp +} +@Composable +private fun Discover( + query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String) -> Unit, + cards: HashMap, onOpen: (String) -> Unit, +) { + Rise(1) { + ModeHeader( + stringResource(R.string.store_tab_discover), query, onQuery, stringResource(R.string.store_search_placeholder), + ) { + // The categories as small pills, at the right of the header. + val pills = rememberScrollState() + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.widthIn(max = 420.dp).edgeFade(pills).horizontalScroll(pills), + ) { + FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id) } } + } + } + } + val results = StoreState.searchResults + if (query.isNotBlank() || category != null) { + when { + StoreState.searching && results == null -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_searching)) } } + results.isNullOrEmpty() -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_results)) } } + else -> Rise(2) { CardGrid(results.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, cards, onOpen) } + } + return + } + if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) { + Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_unreachable)) } } + Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } } + return + } + StoreState.SECTIONS.forEachIndexed { i, (key, title) -> + val apps = StoreState.sections[key] + Rise(2 + i) { Shelf(stringResource(title), apps?.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, first = i == 0, cards, onOpen) } + } +} + +@Composable +private fun InstalledMode(cards: HashMap, onOpen: (String) -> Unit) { + val ctx = LocalContext.current + val apps = StoreState.installed + Rise(1) { + ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_tab_installed), apps.size), "", {}, "") { + SmallLink(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = !StoreState.checkingUpdates && StoreState.busy == null) { + StoreState.checkUpdates(ctx) + } + } + } + if (apps.isEmpty()) { Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_apps)) } }; return } + Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) } +} + +@Composable +private fun UpdatesMode(cards: HashMap, onOpen: (String) -> Unit) { + val ctx = LocalContext.current + val apps = StoreState.installed.filter { it.id in StoreState.updates } + Rise(1) { + ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_updates), apps.size), "", {}, "") { + PrimaryButton(stringResource(R.string.store_update_all, apps.size), compact = true, enabled = StoreState.busy == null) { + StoreState.update(ctx, null, ctx.getString(R.string.store_updates)) + } + } + } + Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) } +} + +/** A horizontal row of cards under a title, the stores' shelf; nothing while it is empty. */ +@Composable +private fun Shelf(title: String, apps: List?, first: Boolean, cards: HashMap, onOpen: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth()) { + Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 14.dp, bottom = 6.dp)) + if (apps == null) Text(stringResource(R.string.store_section_loading), fontSize = 13.sp, color = colors.onSurfaceVariant) + else if (apps.isNotEmpty()) BoxWithConstraints(Modifier.fillMaxWidth()) { + val size = cardSize(maxWidth) + val shelf = rememberScrollState() + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().edgeFade(shelf).horizontalScroll(shelf).padding(vertical = 2.dp)) { + apps.take(18).forEachIndexed { i, app -> key(app.id) { AppCard(app, size, first && i == 0, cards) { onOpen(app.id) } } } + } + } + } +} + +/** The Installed and Updates modes, and search: a grid of the same cards. */ +@Composable +private fun CardGrid(apps: List, cards: HashMap, onOpen: (String) -> Unit) { + BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 10.dp)) { + val size = cardSize(maxWidth) + val columns = ((maxWidth + 10.dp) / (size + 10.dp)).toInt().coerceAtLeast(1) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + apps.chunked(columns).forEachIndexed { r, row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + row.forEachIndexed { i, app -> key(app.id) { AppCard(app, size, r == 0 && i == 0, cards) { onOpen(app.id) } } } + } + } + } + } +} + +/** + * One app: its icon over a blurred, darkened copy of itself (darkened only, before Android 12), + * with one status on the art - Get, ✓, or how far an install is - and under it the name, on two + * lines if it needs them, and what the app is. + */ +@Composable +private fun AppCard(app: CardApp, size: Dp, first: Boolean, cards: HashMap, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale") + val shape = RoundedCornerShape(16.dp) + val installed = StoreState.installed.any { it.id == app.id } + val update = app.id in StoreState.updates + val busy = StoreState.busy == app.id + val artH = size * 0.62f + Column( + Modifier.width(size).paneItem("tile:store:${app.id}").then(if (first) Modifier.firstTile() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .semantics { contentDescription = app.name }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { cards[app.id] = it.boundsInRoot() } + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(Color(0xFF0B2E2B)), + ) { + if (app.icon != null) { + // The icon's own colour, blurred and darkened, as the card's ground. + AsyncImage( + model = app.icon, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.55f } + .then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + AsyncImage(model = app.icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.62f)) + } else Icon(Icons.Outlined.Apps, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.5f)) + // One status, on the art: what A on the card leads to. + Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) { + when { + busy -> Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) { + if (StoreState.percent >= 0) CircularProgressIndicator(progress = { StoreState.percent / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp)) + else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp)) + } + else -> Text( + when { + update -> stringResource(R.string.common_update) + installed -> "✓" + else -> stringResource(R.string.comp_get) + }, + fontSize = 11.sp, fontWeight = FontWeight.Bold, maxLines = 1, + color = if (installed && !update) FlathubInk else pal.onSignal, + modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(if (installed && !update) pal.good else pal.signal) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } + Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) { + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + app.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, + maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), + ) + if (app.verified) Icon(Icons.Outlined.Verified, stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.padding(top = 2.dp).size(13.dp)) + } + // Two lines kept for the summary even when it is short, so cards in a row line up. + Text( + app.summary.orEmpty(), fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, + minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp), + ) + } + } +} + +/** + * An app's page, flooded out of its card: its icon, name and what it is, one action (Install or + * Open, with Update beside it when there is one) and Website; then its screenshots and details. + * Install flies a dot to the Installed chip; Remove asks by stepping out of its button, in red. + */ @Composable private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: () -> Unit) { val ctx = LocalContext.current @@ -296,44 +636,53 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: val d = StoreState.details[id] val local = StoreState.installed.firstOrNull { it.id == id } val name = d?.name ?: local?.name ?: id - Rise(0) { BackLink(stringResource(R.string.store_title), onClick = onBack) } + val busyHere = StoreState.busy == id + val installed = local != null + Rise(0) { BackLink(stringResource(R.string.flathub_name), onClick = onBack) } Rise(1) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) { AppIcon(d?.icon ?: local?.icon ?: FlathubApi.iconUrl(id), name, 72) Column(modifier = Modifier.weight(1f)) { Text(name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) - d?.developer?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } + val meta = listOfNotNull( + d?.developer, d?.version?.let { "v$it" }, + d?.downloadSize?.takeIf { it > 0 }?.let { stringResource(R.string.store_download_size, FileUtils.sizeToString(it)) }, + ).joinToString(" · ") + if (meta.isNotEmpty()) Text(meta, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) (d?.summary ?: local?.summary)?.let { Text(it, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) } } } } - val busyHere = StoreState.busy == id - val installed = local != null - // Remove asks twice: one stray press of A should not cost a download. The second press has - // to come within a few seconds; the app's own data in ~/.var/app stays either way. - var confirmRemove by remember(id) { mutableStateOf(false) } - LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } } Rise(2) { Actions { if (installed) { - PrimaryButton(stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) } + PrimaryButton(if (busyHere) stringResource(R.string.common_working) else stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) } if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) } - SecondaryButton(if (busyHere) stringResource(R.string.common_working) else if (confirmRemove) stringResource(R.string.store_press_again) else stringResource(R.string.store_remove), enabled = StoreState.busy == null) { - if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true - } } else { - PrimaryButton( - if (busyHere) stringResource(R.string.store_installing) else stringResource(R.string.store_install), main = true, - enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches), - ) { StoreState.install(ctx, id, name) } - if (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_first), ok = false) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + PrimaryButton( + if (busyHere) (if (StoreState.percent >= 0) "${StoreState.stage ?: stringResource(R.string.store_installing)} · ${StoreState.percent}%" else stringResource(R.string.store_installing)) + else stringResource(R.string.store_install), + main = true, + enabled = StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches), + ) { + StoreState.install(ctx, id, name) + // The button squashes into a dot that flies to the Installed chip, whose count pops. + val from = at + if (from != null) Flights.fly(Flight(from, to = { FlathubMotion.installedChip?.center }, onLand = { FlathubMotion.landed++ })) + else FlathubMotion.landed++ + } + } } - d?.version?.let { ActionChip("v$it", ok = false) } - if (d != null && d.downloadSize > 0) ActionChip(stringResource(R.string.store_download_size, FileUtils.sizeToString(d.downloadSize)), ok = false) - if (installed) ActionChip(stringResource(R.string.store_installed_chip), ok = true) + d?.homepage?.let { url -> + SecondaryButton(stringResource(R.string.store_website)) { + runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))) } + } + } + if (installed) RemoveButton(id, name, enabled = StoreState.busy == null) } } - if (busyHere) BusyBar() if (d == null) { Rise(3) { Box(Modifier.padding(top = 14.dp)) { @@ -346,7 +695,8 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: if (d.screenshots.isNotEmpty()) { Rise(3) { SectionTitle(stringResource(R.string.store_screenshots), null) } Rise(3) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + val shots = rememberScrollState() + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().edgeFade(shots).horizontalScroll(shots)) { d.screenshots.take(6).forEach { shot -> AsyncImage( model = shot.thumb, contentDescription = null, contentScale = ContentScale.Crop, @@ -379,52 +729,45 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: } } -/** Apps as wide tiles: icon, name, summary - three across, or a list on a narrow page. */ +/** Remove, which asks first: are you sure steps out of it in red, Keep first, so a stray A costs nothing. */ @Composable -private fun AppGrid(apps: List, first: Boolean, onOpen: (String) -> Unit) { - val columns = if (LocalNarrowPane.current) 1 else 3 - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) { - apps.chunked(columns).forEachIndexed { r, row -> - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - row.forEachIndexed { i, app -> - key(app.id) { - AppTile(app, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onOpen(app.id) } - } +private fun RemoveButton(id: String, name: String, enabled: Boolean) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.store_remove) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled) { + val from = at ?: return@SecondaryButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 3, + handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) }, + ) { + StepTitle(ctx.getString(R.string.flathub_remove_title, name)) + Text(ctx.getString(R.string.flathub_remove_note), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(2)) { + StepChoice(ctx.getString(R.string.flathub_keep), enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); StoreState.uninstall(ctx, id, name) } } - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } + }) } } } +/** A small text action at the right of a header. */ @Composable -private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { +private fun SmallLink(text: String, enabled: Boolean = true, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "appScale") - val installed = StoreState.installed.any { it.id == app.id } - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier.paneItem("tile:store:${app.id}").then(if (isFirst) Modifier.firstTile() else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) { - AppIcon(app.icon, app.name, 44) - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.size(14.dp)) - } - Text(if (installed) stringResource(R.string.store_tab_installed) else app.summary, fontSize = 13.sp, color = if (installed) pal.good else colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } + val hot = rememberHot(src) && enabled + Text( + text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (!enabled) colors.onSurfaceVariant.copy(alpha = 0.5f) else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, role = Role.Button, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick).padding(horizontal = 8.dp, vertical = 12.dp), + ) } /** An app's icon from Flathub (a URL) or from its installed files; a placeholder until it loads. */ @@ -446,13 +789,13 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { val hot = rememberHot(src) val shape = RoundedCornerShape(99.dp) Text( - label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, - modifier = Modifier.paneItem("pill:$label").clip(shape) + modifier = Modifier.paneItem("pill:$label").heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape) .background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 9.dp), + .padding(horizontal = 12.dp, vertical = 7.dp), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index d55b82805..8f902df1c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -24,6 +24,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.material.icons.filled.Settings +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer @@ -490,31 +493,36 @@ internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) { } /** - * The chip row's cog: the section's own settings in a small card - which tab a store opens on - * and the speed tier. Setup › Stores repeats them under its gate. Every install is added to Steam; - * that is not a choice. + * The chip row's cog: the section's own settings stepped out of it - which tab a store opens on, + * mature titles, the speed tier and how many at once, and the download engine. Their only home; + * Setup no longer repeats them. Every install is added to Steam; that is not a choice. [s] is + * read live, so a change shows at once in what is stepped out. */ -@Composable -internal fun StoresSettingsDialog(s: FrontEndState, a: FrontEndActions, onDismiss: () -> Unit) { - val shown = rememberShown(onDismiss) - val close = { shown.targetState = false } - AppDialog(shown, close, "storesSettings", wide = false, maxWidth = 440.dp) { - DialogHeader(stringResource(R.string.stores_settings_eyebrow), stringResource(R.string.stores_title)) - Rise(1) { - Column { - SettingsRow(stringResource(R.string.setup_stores_open_on), null) { - SegmentedTabs( - listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)), - s.storesOpenTab, - ) { a.onStoresOpenTab(it) } - } - SettingsRow(stringResource(R.string.stores_show_mature), null) { - ToggleSwitch(s.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) } - } - // The queue's two knobs as one compact row, the same control the Downloads page has. - Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(s, a) } +internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color): StepAsk = + StepAsk( + anchor = at, side = StepSide.Left, accent = accent, items = 3, maxWidth = 440.dp, + handle = { androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Filled.Settings, null, tint = accent, modifier = Modifier.size(20.dp)) }, + ) { + StepTitle(stringResource(R.string.stores_settings_eyebrow)) + Column( + Modifier.stepItem(1).fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + val st = s.value + SettingsRow(stringResource(R.string.setup_stores_open_on), null) { + SegmentedTabs( + listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)), + st.storesOpenTab, + ) { a.onStoresOpenTab(it) } } + SettingsRow(stringResource(R.string.stores_show_mature), null) { + ToggleSwitch(st.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) } + } + // The queue's two knobs as one compact row, the same control the Downloads page has. + Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(st, a) } + StoresEngineRow() + } + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().stepItem(2).padding(top = 8.dp)) { + StepChoice(stringResource(R.string.common_ok), enabled = open, modifier = Modifier.focusRequester(first)) { StoresMotion.fold() } } - Rise(2) { Actions { SecondaryButton(stringResource(R.string.common_ok), onClick = close) } } } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresMotion.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresMotion.kt index 3072b2611..9f216ebda 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresMotion.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresMotion.kt @@ -83,7 +83,8 @@ internal object StoresMotion { fun fly(from: Rect) { if (Motion.scale == 0f || downloadsChip == null) { landed++; return } - flights += Flight(from, SystemClock.uptimeMillis()) + // The count sits at the chip's right end. + flights += Flight(from, to = { downloadsChip?.let { Offset(it.right - 22.dp.toPx(), it.center.y) } }, onLand = { landed++ }) } /** The confirm or list stepping out of a control on the Stores page now, and whether it is open. */ @@ -140,8 +141,6 @@ internal object StoresMotion { /** A game card at [bounds] (root px) with corner [corner] (px) and its art, for the page it opens to flood out of. */ internal class CardMark(val key: String, val bounds: Rect, val corner: Float, val art: String?) -internal class Flight(val from: Rect, val id: Long) - /** * A confirm or short list for [StepOut] on the Stores page, asked by a control that sits at [anchor] * (root px). [content] gets a close that folds it; [onDismiss] runs once it has folded, however. @@ -153,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, ) @@ -167,63 +167,21 @@ internal class SignInFlood(val store: Store, val from: Rect, val start: Color, v /** The dots flying to the Downloads chip, drawn over the page whose root offset is [origin]. */ @Composable internal fun FlightLayer(origin: Offset) { - for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) } + for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) { StoresMotion.flights.remove(f) } } } /** - * One install's dot: the button squashes into a 14dp dot (width first, a little bloop), which then - * flies on an arc to the Downloads chip, stretched along its path like the focus ring's drop. + * Pops (1.3 to 1, with one ring) each time [trigger] changes: by default each time a download's + * dot lands, for the count on the chip and the rail; any other landing passes its own count. */ -@Composable -private fun FlightDot(f: Flight, origin: Offset) { - val pal = LocalPalette.current - val t = remember { Animatable(0f) } - LaunchedEffect(Unit) { - t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing)) - t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing)) - StoresMotion.landed++ - StoresMotion.flights.remove(f) - } - Canvas(Modifier.fillMaxSize()) { - val r = 7.dp.toPx() - val from = f.from.translate(-origin) - val chip = (StoresMotion.downloadsChip ?: return@Canvas).translate(-origin) - val a = from.center - // The count sits at the chip's right end. - val b = Offset(chip.right - 22.dp.toPx(), chip.center.y) - val v = t.value - if (v < 1f) { - // Squash: the width goes first, then the height, into the dot; a bloop at the end. - val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f)) - val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f)) - val k = 1f - 0.15f * sin(PI * v).toFloat() - drawRoundRect(pal.signal, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2)) - } else { - val p = v - 1f - val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx()) - val q = 1f - p - val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y) - val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y)) - val stretch = sin(PI * p).toFloat() - val angle = atan2(d.y, d.x) * 180f / PI.toFloat() - rotate(angle, pos) { - val w = 2 * r * (1f + 0.5f * stretch) - val h = 2 * r * (1f - 0.25f * stretch) - drawOval(pal.signal, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h)) - } - } - } -} - -/** Pops (1.3 to 1, with one ring) each time a download's dot lands; for the count on the chip and the rail. */ -internal fun Modifier.landingPop(): Modifier = composed { +internal fun Modifier.landingPop(trigger: Int = StoresMotion.landed): Modifier = composed { val pal = LocalPalette.current val scale = remember { Animatable(1f) } val ring = remember { Animatable(1f) } - val seen = remember { intArrayOf(StoresMotion.landed) } - LaunchedEffect(StoresMotion.landed) { - if (StoresMotion.landed == seen[0]) return@LaunchedEffect - seen[0] = StoresMotion.landed + val seen = remember { intArrayOf(trigger) } + LaunchedEffect(trigger) { + if (trigger == seen[0]) return@LaunchedEffect + seen[0] = trigger coroutineScope { launch { scale.snapTo(1.3f); scale.animateTo(1f, Motion.sp(0.45f, 500f)) } launch { ring.snapTo(0f); ring.animateTo(1f, Motion.tw(400)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index a23c9ebaf..27c6d7ff1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -160,7 +160,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") } var query by rememberSaveable { mutableStateOf("") } var openGame by rememberSaveable { mutableStateOf(null) } - var settings by rememberSaveable { mutableStateOf(false) } + // The cog's settings read the front end's state live while stepped out. + val liveState = androidx.compose.runtime.rememberUpdatedState(s) LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } } val store = Store.byId(chip) // A tab this store does not have (Store or All on Amazon) reads as Library. @@ -337,7 +338,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { StoreTheme(Store.byId(focusedChip ?: chip)) { Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, - onFocusChip = { focusedChip = it }, onSettings = { settings = true }) } + onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) } } } val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() } @@ -475,7 +476,6 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier seenActive.clear(); seenActive.addAll(activeKeys) if (fresh.isNotEmpty()) StoresMotion.takeInstall()?.let { StoresMotion.fly(it) } } - if (settings) StoresSettingsDialog(s, a) { settings = false } StoresState.pendingInstall?.let { item -> InstallWhere(item) } } @@ -571,7 +571,7 @@ private fun SignInFloodLayer(hostAt: Offset) { */ @Composable private fun StoreChips( - selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: () -> Unit, + selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, /** The chip a pad's focus is on (null when it leaves the row): the page shows that store. */ onFocusChip: (String?) -> Unit, ) { @@ -621,75 +621,16 @@ private fun StoreChips( trail = if (active > 0) { { CountPill(active) } } else null, onFocused = onFocusChip, ) { onDownloads() } - IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings), onSettings) + var cogAt by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { + IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } + } } // Over the chips: the fill is translucent, and the other chips' rest fill would hide it in flight. ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint) } } -/** - * The selection under the chips: [at] (in the row) of chip [index], in [tint]. A move to the next - * chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a beat - * later on its trail spring - and a hop past a chip pinches it to a drop, carries it and opens it. - */ -@Composable -private fun BoxScope.ChipGlide(index: Int, at: Rect?, tint: State) { - val density = androidx.compose.ui.platform.LocalDensity.current - val left = remember { Animatable(Float.NaN) } - val right = remember { Animatable(0f) } - val pinch = remember { Animatable(0f) } - val top = remember { mutableStateOf(0f) } - val bottom = remember { mutableStateOf(0f) } - val was = remember { intArrayOf(-1) } - LaunchedEffect(index, at) { - val b = at ?: return@LaunchedEffect - top.value = b.top; bottom.value = b.bottom - val from = was[0] - was[0] = index - if (left.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) { - left.snapTo(b.left); right.snapTo(b.right); pinch.snapTo(0f) - return@LaunchedEffect - } - coroutineScope { - if (kotlin.math.abs(index - from) > 1) { - // Too far to stretch: pinch into a drop where it is, carry it over, open it there. - pinch.animateTo(1f, Motion.tw(90)) - val l = launch { left.animateTo(b.left, Motion.sp(0.7f, 380f)) } - val r = launch { right.animateTo(b.right, Motion.sp(0.7f, 380f)) } - l.join(); r.join() - pinch.animateTo(0f, Motion.sp(0.6f, 500f)) - } else { - val forward = index > from - val (lead, trail) = if (forward) right to left else left to right - val leadTo = if (forward) b.right else b.left - val trailTo = if (forward) b.left else b.right - launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) } - launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) } - } - } - } - androidx.compose.foundation.Canvas(Modifier.matchParentSize()) { - if (left.value.isNaN() || bottom.value <= top.value) return@Canvas - val l = minOf(left.value, right.value) - val r = maxOf(left.value, right.value) - val cx = (l + r) / 2f - val cy = (top.value + bottom.value) / 2f - val drop = with(density) { 6.dp.toPx() } - val p = pinch.value - val x0 = androidx.compose.ui.util.lerp(l, cx - drop, p) - val x1 = androidx.compose.ui.util.lerp(r, cx + drop, p) - val y0 = androidx.compose.ui.util.lerp(top.value, cy - drop, p) - val y1 = androidx.compose.ui.util.lerp(bottom.value, cy + drop, p) - val size = androidx.compose.ui.geometry.Size(x1 - x0, y1 - y0) - val corner = androidx.compose.ui.geometry.CornerRadius(minOf(size.width, size.height) / 2f) - val at0 = Offset(x0, y0) - // A drop in flight is solid; on a chip it is the chip's tinted fill and outline. - drawRoundRect(tint.value.copy(alpha = androidx.compose.ui.util.lerp(0.16f, 0.9f, p)), at0, size, corner) - drawRoundRect(tint.value.copy(alpha = 0.7f * (1f - p)), at0, size, corner, style = androidx.compose.ui.graphics.drawscope.Stroke(with(density) { 1.dp.toPx() })) - } -} - @Composable private fun StoreChip( label: String, on: Boolean, key: String, modifier: Modifier, lead: @Composable () -> Unit, @@ -945,7 +886,7 @@ private fun Storefront( * search field across the row, and [trailing] (the account) at the right, over a rule. */ @Composable -private fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) { +internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current // Open while it holds a query; an empty field closes again once focus leaves it. @@ -1001,6 +942,22 @@ internal fun storePalette(base: Palette, store: Store?): Palette { ) } +/** The Store section's accent: Flathub's teal, with its dark ink on it. */ +internal fun flathubPalette(base: Palette): Palette = Palette( + base.id, base.label, base.detail, base.background, base.surface, base.surfaceVariant, base.line, base.line2, + base.onBackground, base.onSurfaceVariant, primary = FlathubTeal, primary2 = Color(0xFF2E958D), onPrimary = FlathubInk, signal = FlathubTeal, + good = base.good, error = base.error, +) + +/** [content] with the focus ring, buttons, step-outs and the chip glide in Flathub's teal. */ +@Composable +internal fun FlathubTheme(content: @Composable () -> Unit) { + val base = LocalPalette.current + val p = remember(base) { flathubPalette(base) } + val scheme = MaterialTheme.colorScheme.copy(primary = p.primary, onPrimary = p.onPrimary) + CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, typography = MaterialTheme.typography, content = content) } +} + /** [content] with the focus ring, buttons and step-outs in [store]'s colour. */ @Composable internal fun StoreTheme(store: Store?, content: @Composable () -> Unit) { @@ -1060,7 +1017,7 @@ private fun SignOutButton(store: Store, name: String) { /** The header's magnifier: a round button the search field opens out of. */ @Composable -private fun SearchIcon(description: String, onClick: () -> Unit) { +internal fun SearchIcon(description: String, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3a99dc295..d22ccfba5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -642,9 +642,6 @@ Elige una app de inicio Elegir Apps de Linux (beta) - Tienda Flathub - En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck. - Instala apps y juegos de Linux desde Flathub Archivos Explorar y gestionar archivos Versiones de Proton diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 82bb88788..cac95004b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -518,9 +518,6 @@ Choisir l\'application d\'écran d\'accueil Choisir Applications Linux (bêta) - Boutique Flathub - Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck. - Installer des applications et des jeux Linux depuis Flathub Fichiers Parcourir et gérer les fichiers Versions de Proton diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 771191479..a788642f6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -638,9 +638,6 @@ ホームアプリを選択 選択 Linuxアプリ(ベータ版) - Flathubストア - メニューに表示されます。一部のアプリは起動できない場合があります。ログはDownload/DroidDeckにあります。 - FlathubからLinuxアプリとゲームをインストール ファイル ファイルの閲覧と管理 Protonバージョン diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 71e537e51..6d5ef3a49 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -638,9 +638,6 @@ 홈 앱 선택 선택 Linux 앱(베타) - Flathub 스토어 - 메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다. 로그는 Download/DroidDeck에 있습니다. - Flathub에서 Linux 앱 및 게임 설치 파일 파일 탐색 및 관리 Proton 버전 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 071cc5646..be7523541 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -596,9 +596,6 @@ Выбрать главный экран Выбрать Приложения Linux (бета) - Магазин Flathub - Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck. - Устанавливать приложения и игры Linux из Flathub Файлы Просмотр и управление файлами Версии Proton diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 95e14ffdf..4386993cb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -638,9 +638,6 @@ 选择主屏幕应用 选择 Linux 应用(测试版) - Flathub 商店 - 显示在菜单中。部分应用可能无法启动;日志位于 Download/DroidDeck。 - 从 Flathub 安装 Linux 应用和游戏 文件 浏览和管理文件 Proton 版本 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 5c529e6c9..1cb3f71b5 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -638,9 +638,6 @@ 選擇主畫面應用程式 選擇 Linux 應用程式(測試版) - Flathub 商店 - 顯示在選單中。部分應用程式可能無法啟動;記錄位於 Download/DroidDeck。 - 從 Flathub 安裝 Linux 應用程式和遊戲 檔案 瀏覽及管理檔案 Proton 版本 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4b5c09a09..2078bafbb 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -638,9 +638,6 @@ 選擇主螢幕應用程式 選擇 Linux 應用程式(測試版) - Flathub 商店 - 顯示在選單中。部分應用程式可能無法啟動;日誌位於 Download/DroidDeck。 - 從 Flathub 安裝 Linux 應用程式和遊戲 檔案 瀏覽和管理檔案 Proton 版本 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f9e1b604..4537ab369 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -658,9 +658,6 @@ Choose a Home app Choose Linux apps (beta) - Flathub Store - In the menu. Some apps may not start; logs are in Download/DroidDeck. - Install Linux apps and games from Flathub Files Browse and manage files Proton versions @@ -1993,7 +1990,6 @@ Steam Custom Stores - Show Stores in the rail Open a store on Download speed tier Stores engine @@ -2183,4 +2179,50 @@ %1$d already in Games %1$d already in Games + + B · Back to %1$s + the game + Play settings + + Graphics drivers + + This Proton + Every Proton + For every Proton + Available · %1$d + Checked %1$s + Check for new components and drivers + Get + %1$s · DXVK, VKD3D-Proton and FEX below are this Proton\'s + %1$s · tested + %1$s · untested + %1$s · unsupported + Keeps the recommended pair current + Auto · %1$s + Runtime and display drivers separately + Use %1$s? + Remove %1$s? + + Diagnostics + Ready to play + Not ready to play yet + runtime %1$s + process limit off + signed in as %1$s + When something needs you + Look + This device + Library art + + LINUX APPS + Flathub + Desktop apps for the Linux desktop, opened from Desktop. + Set up Flathub + Remove %1$s? + Its settings and files are kept. + Keep + + Size on disk + Uninstall the desktop? + All diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GlideShapeTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GlideShapeTest.kt new file mode 100644 index 000000000..8a9122505 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GlideShapeTest.kt @@ -0,0 +1,29 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GlideShapeTest { + @Test fun restingAcrossCoversTheChip() { + val r = glideShape(10f, 90f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false) + assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f) + assertEquals(0f, r.top, 0.01f); assertEquals(40f, r.bottom, 0.01f) + } + + @Test fun edgesInEitherOrder() { + val r = glideShape(90f, 10f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false) + assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f) + } + + @Test fun pinchedIsATwelveDpDropAtTheCentre() { + val r = glideShape(10f, 90f, 0f, 40f, pinch = 1f, drop = 6f, vertical = false) + assertEquals(12f, r.width, 0.01f); assertEquals(12f, r.height, 0.01f) + assertEquals(50f, r.center.x, 0.01f); assertEquals(20f, r.center.y, 0.01f) + } + + @Test fun verticalRunsTheAxisDown() { + val r = glideShape(100f, 160f, 4f, 84f, pinch = 0f, drop = 6f, vertical = true) + assertEquals(4f, r.left, 0.01f); assertEquals(84f, r.right, 0.01f) + assertEquals(100f, r.top, 0.01f); assertEquals(160f, r.bottom, 0.01f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt new file mode 100644 index 000000000..b25eea49e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt @@ -0,0 +1,20 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class InPaneTest { + @Test fun aRailItemLeftOfThePaneStartsHalfInside() { + val o = inPane(Origin(Rect(-200f, 300f, -20f, 420f), 14f)) + assertEquals(-90f, o.bounds.left, 0.01f) + assertEquals(90f, o.bounds.right, 0.01f) + assertEquals(300f, o.bounds.top, 0.01f) + } + + @Test fun aControlInThePaneStaysPut() { + val origin = Origin(Rect(40f, 300f, 200f, 360f), 12f) + assertSame(origin, inPane(origin)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LayerStepTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LayerStepTest.kt new file mode 100644 index 000000000..a8581d893 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/LayerStepTest.kt @@ -0,0 +1,24 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LayerStepTest { + @Test fun theStackRunsFromTheProtonDownToTheGpuDrivers() { + assertEquals(listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB), LAYERS) + } + + @Test fun rbStepsDownAndWraps() { + assertEquals("dxvk", layerStep(PROTON_TAB, 1)) + assertEquals(PROTON_TAB, layerStep(GPU_TAB, 1)) + } + + @Test fun lbStepsUpAndWraps() { + assertEquals("fex", layerStep(GPU_TAB, -1)) + assertEquals(GPU_TAB, layerStep(PROTON_TAB, -1)) + } + + @Test fun anUnknownLayerStartsFromTheTop() { + assertEquals("dxvk", layerStep("tab-from-an-older-build", 1)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt new file mode 100644 index 000000000..c5b8003b9 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt @@ -0,0 +1,18 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LiftSheetTest { + @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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/PageExitTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/PageExitTest.kt new file mode 100644 index 000000000..b54248f40 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/PageExitTest.kt @@ -0,0 +1,31 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PageExitTest { + @Test fun backToTheSameRailItemDrainsIntoTheControl() { + assertEquals(PageExit.Drain, pageExit("steam", "steam", PageLeave.Ordinary)) + } + + @Test fun anotherRailItemSinks() { + assertEquals(PageExit.Sink, pageExit("steam", "games", PageLeave.Ordinary)) + } + + @Test fun anotherPageSinks() { + // The GPU drivers row in Steam's settings opens Components over the same rail item. + assertEquals(PageExit.Sink, pageExit("steam", "components", PageLeave.Ordinary)) + } + + @Test fun aLinkIntoAnotherSectionHops() { + assertEquals(PageExit.Hop, pageExit("steam", "components", PageLeave.Link)) + } + + @Test fun drainingWinsWhenTheOwnerComesBackAnyway() { + assertEquals(PageExit.Drain, pageExit("desktop", "desktop", PageLeave.Link)) + } + + @Test fun aPageWithNoOwnerNeverDrains() { + assertEquals(PageExit.Sink, pageExit(null, "steam", PageLeave.Ordinary)) + } +} From 715032062d6246d4ed4fb30ba131191e9cec2a31 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Sun, 11 Oct 2026 15:32:07 -0400 Subject: [PATCH 2/6] feat: DroidDeck-Proton as a Proton source, and update checks for every source Setup > Proton versions lists DroidDeck-Proton (The412Banner/DroidDeck-Proton) beside GE-Proton and proton-cachyos: same aarch64 tarball pattern as GE, verified against its sha512 file, installed by droiddeck-proton-extra (also as "droiddeck" in the session queue) and adopted by steam-compatibility like any other extra, so it shows as "DroidDeck-Proton (Compatible)". Its folder has a space; nothing in the install, adopt, esync or components paths splits on it. Update checks: each source's newest full release is looked up at app start and when the page opens, at most every 6 h (offline keeps the last answer). A newer release shows "Update available -> " with Update; it installs beside the old build, which stays until removed ("Remove old ... versions"), since games are mapped to it by name. Installed builds are ordered by their version numbers (11-9 before 11-10), not by name, and the tag each was installed from is recorded. Prereleases and drafts are skipped. --- .../com/droiddeck/launcher/MainActivity.kt | 6 +- .../java/com/droiddeck/launcher/ProtonMenu.kt | 27 ++- .../launcher/session/ProtonExtras.kt | 172 ++++++++++++++--- .../droiddeck/launcher/ui/ComponentsPage.kt | 55 ++++-- app/src/main/res/values/strings.xml | 11 +- .../launcher/session/ProtonExtrasTest.kt | 179 ++++++++++++++++++ .../usr/local/bin/droiddeck-proton-extra | 20 +- .../overlay/usr/local/bin/droiddeck-session | 6 +- tools/tests/test_esync_packs.py | 19 ++ tools/tests/test_game_environment.py | 34 ++++ tools/tests/test_proton_extra.py | 107 +++++++++++ 11 files changed, 575 insertions(+), 61 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt create mode 100644 tools/tests/test_proton_extra.py diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 58d8599f5..187c7f54a 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -994,6 +994,8 @@ class MainActivity : ComponentActivity() { updates.onResume() // Auto keeps this GPU's recommended driver pair installed and set: once per app start. drivers.ensureAuto(force = false) + // Newer GE-Proton / proton-cachyos / DroidDeck-Proton releases: looked up every few hours at most. + protons.checkUpdates() // Swaps queued while a game ran on that Proton go in once nothing uses it (usually the // session has just ended). Cheap when nothing is queued. if (!SessionState.running) Thread({ @@ -1145,6 +1147,7 @@ class MainActivity : ComponentActivity() { components.refreshComponents(snapshotFirst = true) drivers.refreshDrivers() protons.refreshProtons() + protons.checkUpdates() } private fun importComponent(uri: Uri) { @@ -1203,7 +1206,8 @@ class MainActivity : ComponentActivity() { protonActions = com.droiddeck.launcher.ui.ProtonsActions( onInstall = { id -> protons.installProton(id) }, onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() }, - onRemove = { id -> protons.removeProton(id) }, + onRemove = { id, dir -> protons.removeProton(id, dir) }, + onRemoveOlder = { id -> protons.removeProton(id, older = true) }, ), protonOptions = { com.droiddeck.launcher.ui.ProtonOptions( diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt index 8bf520818..e73babbc1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt @@ -10,14 +10,16 @@ import com.droiddeck.launcher.ui.ProtonRow import com.droiddeck.launcher.session.SessionState /** - * The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or - * removal in progress. The launcher screen keeps one and shows it. + * The optional ARM64 Protons (GE-Proton, proton-cachyos, DroidDeck-Proton): the rows the page lists, + * an install or removal in progress, and whether a newer release is out. The launcher screen keeps + * one and shows it. */ internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) { var protonRows by mutableStateOf>(emptyList()) var protonBusyId by mutableStateOf(null) var protonStage by mutableStateOf(null) var protonPercent by mutableIntStateOf(-1) + private val checking = java.util.concurrent.atomic.AtomicBoolean(false) fun installProton(id: String) { val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return @@ -40,14 +42,15 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va }, "install-proton-$id").start() } - fun removeProton(id: String) { + /** Removes the build in [dir], or with [older] every build of it but the newest. */ + fun removeProton(id: String, dir: String? = null, older: Boolean = false) { val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return if (protonBusyId != null || SessionState.running) return protonBusyId = id - protonStage = activity.getString(R.string.main_removing_named, tool.name) + protonStage = activity.getString(R.string.main_removing_named, dir ?: tool.name) protonPercent = -1 Thread({ - ProtonExtras.remove(activity, tool) + if (older) ProtonExtras.removeOlder(activity, tool) else ProtonExtras.remove(activity, tool, dir) ui.post { protonBusyId = null protonStage = null @@ -57,6 +60,18 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va } fun refreshProtons() { - protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) } + protonRows = ProtonExtras.tools.map { + val all = ProtonExtras.installedAll(activity, it) + ProtonRow(it.id, it.name, all.lastOrNull(), ProtonExtras.queued(activity, it), all, ProtonExtras.update(activity, it)) + } + } + + /** Looks for newer releases, at most every few hours (see ProtonExtras.checkLatest); quiet offline. */ + fun checkUpdates(force: Boolean = false) { + if (!checking.compareAndSet(false, true)) return + Thread({ + val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) } + if (asked) ui.post { refreshProtons() } + }, "proton-updates").start() } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt index 268210888..c51cf01f4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt @@ -10,12 +10,15 @@ import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.File import org.json.JSONArray +import org.json.JSONObject /** Immediate download and installation of the optional ARM64 Proton builds. */ object ProtonExtras { private const val TAG = "ProtonExtras" private const val NEED_BYTES = 4L * 1024 * 1024 * 1024 private const val RELEASES = "https://api.github.com/repos/%s/releases?per_page=15" + /** How long a release check stands before the next one goes to GitHub. */ + const val CHECK_EVERY_MS = 6L * 60 * 60 * 1000 @Volatile var installInProgress = false private set @@ -23,23 +26,122 @@ object ProtonExtras { class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex) /** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */ - private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long, + internal data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long, val sha256: String? = null) val tools = listOf( Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")), Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")), + Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "The412Banner/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")), ) private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root") private fun requests(context: Context) = File(home(context), ".bl-proton-extra") private fun toolsDir(context: Context) = File(home(context), ".local/share/Steam/compatibilitytools.d") + /** The release tag each build was installed from, by directory name. */ + private fun releasesFile(context: Context) = File(context.filesDir, "proton-extras.json") + /** The newest release seen per source, and when it was looked up. */ + private fun latestFile(context: Context) = File(context.filesDir, "proton-latest.json") - /** The installed build's directory name (e.g. GE-Proton11-7), or null. */ - fun installed(context: Context, tool: Tool): String? = - toolsDir(context).listFiles() + /** The installed build's directory name (e.g. GE-Proton11-7), the newest when there are several, or null. */ + fun installed(context: Context, tool: Tool): String? = installedIn(toolsDir(context), tool).lastOrNull() + + /** Every installed build's directory name, oldest first. */ + fun installedAll(context: Context, tool: Tool): List = installedIn(toolsDir(context), tool) + + internal fun installedIn(dir: File, tool: Tool): List = + dir.listFiles() ?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile } - ?.maxByOrNull { it.name }?.name + ?.map { it.name } + ?.sortedWith { a, b -> compareVersions(a, b) } + ?: emptyList() + + // Architecture names carry digits (aarch64, x86_64) that are not part of a version. + private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64") + private val NUMBER = Regex("\\d+") + + /** The numbers in a release tag or build folder, in order: GE-Proton11-10 is 11, 10. */ + internal fun versionKey(name: String): List = + NUMBER.findAll(ARCH.replace(name, " ")).mapNotNull { it.value.toLongOrNull() }.toList() + + /** Orders by those numbers, so 11-10 is after 11-9; the name itself only breaks a tie. */ + internal fun compareVersions(a: String, b: String): Int { + val x = versionKey(a) + val y = versionKey(b) + for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return x[i].compareTo(y[i]) + return if (x.size != y.size) x.size.compareTo(y.size) else a.compareTo(b) + } + + /** [latest] when it is newer than every installed release, else null. Nothing installed is not an update. */ + internal fun updateFor(installed: List, latest: String?): String? { + if (latest == null || installed.isEmpty()) return null + val newest = installed.maxWithOrNull { a, b -> compareVersions(a, b) } ?: return null + val x = versionKey(latest) + val y = versionKey(newest) + for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return if (x[i] > y[i]) latest else null + return if (x.size > y.size) latest else null + } + + /** The release each installed build came from: the tag recorded at install, else its folder name. */ + fun installedTags(context: Context, tool: Tool): List { + val recorded = readJson(releasesFile(context)) + return installedAll(context, tool).map { recorded.optString(it).ifEmpty { it } } + } + + /** A newer release than the installed one, from the last check (no network), or null. */ + fun update(context: Context, tool: Tool): String? = updateFor(installedTags(context, tool), cachedLatest(context, tool)) + + fun cachedLatest(context: Context, tool: Tool): String? = + readJson(latestFile(context)).optJSONObject(tool.id)?.optString("tag")?.ifEmpty { null } + + /** Its own lock, not the install's: a check must not wait behind a download. */ + private val checkLock = Any() + /** A failed look-up (offline) is not repeated on every resume, only after this long. */ + private const val RETRY_MS = 15L * 60 * 1000 + private var lastAttempt = 0L + + /** + * Looks up each source's newest release unless it was looked up within [CHECK_EVERY_MS] (or + * [force]). Offline or rate limited, the last answer stands. True when anything was asked. + */ + fun checkLatest(context: Context, force: Boolean = false, now: Long = System.currentTimeMillis()): Boolean { + synchronized(checkLock) { + val file = latestFile(context) + val cache = readJson(file) + val due = tools.filter { tool -> + val at = cache.optJSONObject(tool.id)?.optLong("at", 0L) ?: 0L + force || (now - at) !in 0 until CHECK_EVERY_MS + } + if (due.isEmpty() || !force && (now - lastAttempt) in 0 until RETRY_MS) return false + lastAttempt = now + for (tool in due) { + val tag = findLatestAsset(tool)?.tag ?: continue + cache.put(tool.id, JSONObject().put("tag", tag).put("at", now)) + } + runCatching { file.writeText(cache.toString()) } + return true + } + } + + private fun record(context: Context, tool: Tool, dir: String?, tag: String) { + runCatching { + val latest = readJson(latestFile(context)) + val known = latest.optJSONObject(tool.id)?.optString("tag") + if (known.isNullOrEmpty() || compareVersions(tag, known) >= 0) { + latest.put(tool.id, JSONObject().put("tag", tag).put("at", System.currentTimeMillis())) + latestFile(context).writeText(latest.toString()) + } + if (dir != null) { + val releases = readJson(releasesFile(context)) + val present = toolsDir(context).list()?.toSet() ?: emptySet() + releases.keys().asSequence().toList().filter { it !in present }.forEach { releases.remove(it) } + releasesFile(context).writeText(releases.put(dir, tag).toString()) + } + }.onFailure { Log.w(TAG, "record ${tool.id} $tag", it) } + } + + private fun readJson(file: File): JSONObject = + runCatching { JSONObject(FileUtils.readString(file) ?: "{}") }.getOrDefault(JSONObject()) /** A request left by an earlier app version, still consumed by the session shim. */ fun queued(context: Context, tool: Tool): Boolean = @@ -52,9 +154,15 @@ object ProtonExtras { else file.writeText(lines.joinToString("\n") + "\n") } - /** Deletes the installed build; Steam discovers its absence on its next start. */ - fun remove(context: Context, tool: Tool) { - installed(context, tool)?.let { File(toolsDir(context), it).deleteRecursively() } + /** Deletes one installed build ([dir], else the newest); Steam discovers its absence on its next start. */ + fun remove(context: Context, tool: Tool, dir: String? = null) { + val name = if (dir != null) dir.takeIf { it in installedAll(context, tool) } ?: return else installed(context, tool) ?: return + File(toolsDir(context), name).deleteRecursively() + } + + /** Deletes every build but the newest. */ + fun removeOlder(context: Context, tool: Tool) { + installedAll(context, tool).dropLast(1).forEach { File(toolsDir(context), it).deleteRecursively() } } /** Downloads, verifies and registers the latest build without waiting for another session. */ @@ -145,9 +253,12 @@ object ProtonExtras { hostEnv["PROOT_TMP_DIR"] = context.cacheDir.path LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { hostEnv["LD_LIBRARY_PATH"] = it } val child = process.start() + var placed: String? = null child.inputStream.bufferedReader().useLines { lines -> lines.forEach { line -> Log.i(TAG, line) + // "droiddeck-proton-extra: installed ", the folder as the tarball names it. + line.substringAfter("droiddeck-proton-extra: installed ", "").takeIf { it.isNotEmpty() }?.let { placed = it } if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) { onProgress(installing, -1) } @@ -158,6 +269,7 @@ object ProtonExtras { else { archive.delete() unqueue(context, tool) + record(context, tool, placed, asset.tag) if (EsyncPacks.enabled(context)) { onProgress(context.getString(R.string.esync_fetching), -1) try { @@ -177,32 +289,36 @@ object ProtonExtras { private fun findLatestAsset(tool: Tool): Asset? { val releases = Downloader.downloadString(RELEASES.format(tool.repo)) ?: return null return try { - val array = JSONArray(releases) - for (i in 0 until array.length()) { - val release = array.getJSONObject(i) - if (release.optBoolean("draft", false)) continue - val assets = release.optJSONArray("assets") ?: continue - val entries = (0 until assets.length()).map { assets.getJSONObject(it) } - val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue - val name = archive.optString("name") - val stem = name.substringBefore(".tar") - val checksum = entries.firstOrNull { - it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true) - }?.optString("browser_download_url")?.takeIf { it.startsWith("http") } - return Asset( - release.optString("tag_name"), name, - archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum, - archive.optLong("size", 0L), - Hashes.githubSha256(archive.optString("digest")), - ) - } - null + pickAsset(tool, JSONArray(releases)) } catch (e: Exception) { Log.w(TAG, "release metadata for ${tool.id}", e) null } } + /** The newest full release in GitHub's list (newest first) that carries [tool]'s ARM64 archive. */ + internal fun pickAsset(tool: Tool, array: JSONArray): Asset? { + for (i in 0 until array.length()) { + val release = array.getJSONObject(i) + if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue + val assets = release.optJSONArray("assets") ?: continue + val entries = (0 until assets.length()).map { assets.getJSONObject(it) } + val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue + val name = archive.optString("name") + val stem = name.substringBefore(".tar") + val checksum = entries.firstOrNull { + it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true) + }?.optString("browser_download_url")?.takeIf { it.startsWith("http") } + return Asset( + release.optString("tag_name"), name, + archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum, + archive.optLong("size", 0L), + Hashes.githubSha256(archive.optString("digest")), + ) + } + return null + } + private fun requestLines(context: Context): List = FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index e80156695..7eb708117 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -316,7 +316,7 @@ fun ComponentsPage( val availableTitle: @Composable (String) -> String = { l -> when (l) { GPU_TAB -> stringResource(R.string.comp_available_count, gpu.pairs.count { !it.installed && it.complete && (it.suits || showAllPairs) }) - PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null }) + PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null || it.update != null }) else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size) } } @@ -355,7 +355,7 @@ fun ComponentsPage( views.forEachIndexed { i, v -> val key = "proton:${v.proton.id}" val swaps = v.components.values.count { it.activeFile != null } - val extra = protons.rows.firstOrNull { it.installed == v.proton.dir.name } + val extra = protons.rows.firstOrNull { v.proton.dir.name in it.installedAll } val removable = extra != null && protons.busyId == null && protons.runtimeReady && !protons.sessionRunning ChoiceLine( v.proton.name, @@ -365,8 +365,8 @@ fun ComponentsPage( modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt), onSelect = { if (v.proton.id != view?.proton?.id) { onProton(v.proton.id); flyHome(key, PROTON_TAB) } }, onDelete = { - if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), "", ctx.getString(R.string.store_remove), true, v.proton.name) { - protonActions.onRemove(extra.id) + if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), ctx.getString(R.string.proton_remove_body), ctx.getString(R.string.store_remove), true, v.proton.name) { + protonActions.onRemove(extra.id, v.proton.dir.name) } }, ) @@ -416,14 +416,22 @@ fun ComponentsPage( } } PROTON_TAB -> { - val rows = protons.rows.filter { it.installed == null } + // Not installed, and installed with a newer release out: an update goes in beside the + // build it replaces, which stays until it is removed (games are mapped to it by name). + val rows = protons.rows.filter { it.installed == null || it.update != null } if (rows.isEmpty()) EmptyLine(stringResource(R.string.comp_nothing_new)) for (row in rows) GetLine( - row.name, if (row.queued) stringResource(R.string.proton_queued) else stringResource(R.string.proton_not_installed), + row.name, + when { + row.update != null -> stringResource(R.string.proton_update_available, row.update) + row.queued -> stringResource(R.string.proton_queued) + else -> stringResource(R.string.proton_not_installed) + }, progress = if (protons.busyId == row.id) protons.percent else null, stage = if (protons.busyId == row.id) protons.stage else null, enabled = protons.busyId == null && protons.runtimeReady && !protons.sessionRunning, onCancel = if (row.queued && protons.busyId == null && !protons.sessionRunning) ({ protonActions.onCancel(row.id) }) else null, + action = if (row.update != null) stringResource(R.string.common_update) else null, ) { protonActions.onInstall(row.id) } } else -> { @@ -461,7 +469,21 @@ fun ComponentsPage( } } } - PROTON_TAB -> {} + PROTON_TAB -> { + val idle = protons.busyId == null && protons.runtimeReady && !protons.sessionRunning + for (row in protons.rows.filter { it.installedAll.size > 1 }) { + val key = "older:${row.id}" + val older = row.installedAll.dropLast(1) + Box(Modifier.trackRow(key, rowsAt)) { + FocusText(stringResource(R.string.proton_remove_older, row.name), if (idle) pal.signal else colors.onSurfaceVariant) { + if (!idle) return@FocusText + ask(key, false, ctx.getString(R.string.proton_remove_older, row.name) + "?", + ctx.getString(R.string.proton_remove_older_body, older.joinToString(", ")), + ctx.getString(R.string.store_remove), true, row.name) { protonActions.onRemoveOlder(row.id) } + } + } + } + } else -> { SmallButton(stringResource(R.string.comp_import_wcp), onClick = onImport) val st = compState(l) @@ -908,7 +930,7 @@ private fun ChoiceLine( @Composable private fun GetLine( name: String, detail: String, progress: Int?, enabled: Boolean, - badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, onGet: () -> Unit, + badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, action: String? = null, onGet: () -> Unit, ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -923,7 +945,7 @@ private fun GetLine( if (progress != null) ProgressBit(progress) else { if (onCancel != null) FocusText(stringResource(R.string.proton_cancel_queued), pal.signal, onClick = onCancel) - SmallButton(stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet) + SmallButton(action ?: stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet) } } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) @@ -1014,8 +1036,17 @@ class ProtonsState( class ProtonsActions( val onInstall: (String) -> Unit = {}, val onCancel: (String) -> Unit = {}, - val onRemove: (String) -> Unit = {}, + /** The source's id and the installed folder to delete. */ + val onRemove: (String, String) -> Unit = { _, _ -> }, + /** Deletes every build of the source but the newest. */ + val onRemoveOlder: (String) -> Unit = {}, ) -/** A Proton DroidDeck can install, and the folder it is installed as (null when it is not). */ -class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean) +/** + * A Proton DroidDeck can install: the folder its newest build is installed as (null when it is + * not), every installed build oldest first, and a newer release's tag when one is out. + */ +class ProtonRow( + val id: String, val name: String, val installed: String?, val queued: Boolean, + val installedAll: List = listOfNotNull(installed), val update: String? = null, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4537ab369..d621d8310 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -431,7 +431,7 @@ Applies the next time a game starts Import .zip Reading Protons… - No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton or CachyOS from Setup. + No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton, CachyOS or DroidDeck-Proton from Setup. Original · %1$s %1$s · this build %1$s · earlier build @@ -1029,6 +1029,13 @@ Installed %1$s Pending from an earlier request Not installed + + Update available → %1$s + + Remove old %1$s versions + + Removes %1$s. Games set to these versions must be switched to another Proton. + Games set to this version must be switched to another Proton. Install now Cancel queued Android keyboard @@ -1758,7 +1765,7 @@ Downloading the Steam client · %1$s - + A compatibility tool install is already running Stop the active session before installing compatibility tools Could not find an ARM64 release for %1$s diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt new file mode 100644 index 000000000..e551d3052 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt @@ -0,0 +1,179 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ProtonExtrasTest { + @get:Rule val temp = TemporaryFolder() + + private fun tool(id: String) = ProtonExtras.tools.single { it.id == id } + + private fun asset(name: String, digest: String? = null) = JSONObject() + .put("name", name) + .put("browser_download_url", "https://github.com/o/r/releases/download/t/$name") + .put("size", 625_775_670L) + .apply { if (digest != null) put("digest", digest) } + + private fun release(tag: String, vararg assets: JSONObject, prerelease: Boolean = false, draft: Boolean = false) = JSONObject() + .put("tag_name", tag).put("prerelease", prerelease).put("draft", draft) + .put("assets", JSONArray().apply { assets.forEach { put(it) } }) + + private fun droiddeckRelease(ver: String, prerelease: Boolean = false) = release( + "DroidDeck-Proton-$ver", + asset("DroidDeck-Proton-$ver-aarch64.build-info.json"), + asset("DroidDeck-Proton-$ver-aarch64.sha512sum"), + asset("DroidDeck-Proton-$ver-aarch64.tar.gz"), + prerelease = prerelease, + ) + + private fun installTool(parent: File, name: String) = File(parent, name).apply { + mkdirs() + File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }") + } + + @Test + fun theThreeSourcesInOrder() { + assertEquals(listOf("ge", "cachyos", "droiddeck"), ProtonExtras.tools.map { it.id }) + val droiddeck = tool("droiddeck") + assertEquals("DroidDeck-Proton", droiddeck.name) + assertEquals("DroidDeck-Proton", droiddeck.prefix) + assertEquals("The412Banner/DroidDeck-Proton", droiddeck.repo) + assertTrue(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.tar.gz")) + assertTrue(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-8-aarch64.tar.xz")) + assertFalse(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.sha512sum")) + assertFalse(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.build-info.json")) + assertFalse(tool("cachyos").assetPattern.containsMatchIn("proton-cachyos-11.0-20261005-slr-x86_64.tar.xz")) + } + + @Test + fun theTarballAndItsSha512FileArePicked() { + val picked = ProtonExtras.pickAsset(tool("droiddeck"), JSONArray().put(droiddeckRelease("11-7.5")))!! + assertEquals("DroidDeck-Proton-11-7.5", picked.tag) + assertEquals("DroidDeck-Proton-11-7.5-aarch64.tar.gz", picked.name) + assertEquals("https://github.com/o/r/releases/download/t/DroidDeck-Proton-11-7.5-aarch64.sha512sum", picked.sha512) + assertEquals(625_775_670L, picked.size) + assertNull(picked.sha256) + } + + @Test + fun githubsDigestIsKeptBesideTheSha512File() { + val sum = "ab".repeat(32) + val release = release("GE-Proton11-7", asset("GE-Proton11-7-x86_64.tar.gz"), asset("GE-Proton11-7-aarch64.sha512sum"), + asset("GE-Proton11-7-aarch64.tar.gz", "sha256:$sum")) + val picked = ProtonExtras.pickAsset(tool("ge"), JSONArray().put(release))!! + assertEquals("GE-Proton11-7-aarch64.tar.gz", picked.name) + assertEquals(sum, picked.sha256) + assertTrue(picked.sha512!!.endsWith("GE-Proton11-7-aarch64.sha512sum")) + } + + @Test + fun prereleasesDraftsAndReleasesWithoutAnArm64BuildAreSkipped() { + val releases = JSONArray() + .put(droiddeckRelease("11-8", prerelease = true)) + .put(release("DroidDeck-Proton-11-7.9", asset("DroidDeck-Proton-11-7.9-aarch64.tar.gz"), draft = true)) + .put(release("DroidDeck-Proton-11-7.8", asset("DroidDeck-Proton-11-7.8-x86_64.tar.gz"))) + .put(droiddeckRelease("11-7.5")) + assertEquals("DroidDeck-Proton-11-7.5", ProtonExtras.pickAsset(tool("droiddeck"), releases)?.tag) + assertNull(ProtonExtras.pickAsset(tool("droiddeck"), JSONArray().put(droiddeckRelease("11-8", prerelease = true)))) + } + + @Test + fun versionsCompareByTheirNumbers() { + val sorted = listOf("GE-Proton11-10", "GE-Proton10-25", "GE-Proton11-9", "GE-Proton11-1") + .sortedWith { a, b -> ProtonExtras.compareVersions(a, b) } + assertEquals(listOf("GE-Proton10-25", "GE-Proton11-1", "GE-Proton11-9", "GE-Proton11-10"), sorted) + assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10") < 0) + assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7.10", "DroidDeck-Proton 11-8") < 0) + assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7", "DroidDeck-Proton 11-7.1") < 0) + assertTrue(ProtonExtras.compareVersions("cachyos-11.0-20260703-slr", "cachyos-11.0-20261005-slr") < 0) + assertTrue(ProtonExtras.compareVersions("cachyos-10.0-sunset-slr", "cachyos-11.0-20260601-slr") < 0) + // The architecture in a folder name is not part of its version. + assertEquals(listOf(11L, 7L), ProtonExtras.versionKey("GE-Proton11-7-aarch64")) + assertEquals(listOf(11L, 0L, 20261005L), ProtonExtras.versionKey("proton-cachyos-11.0-20261005-slr-arm64")) + assertEquals(listOf(11L, 7L, 5L), ProtonExtras.versionKey("DroidDeck-Proton 11-7.5")) + assertEquals(ProtonExtras.versionKey("DroidDeck-Proton-11-7.5"), ProtonExtras.versionKey("DroidDeck-Proton 11-7.5")) + } + + @Test + fun installedBuildsAreFoundByPrefixNewestLast() { + val tools = temp.newFolder("compatibilitytools.d") + for (name in listOf("GE-Proton11-10", "GE-Proton11-9", "DroidDeck-Proton 11-7.10", "DroidDeck-Proton 11-7.5", + "droiddeck-proton-arm64", "droiddeck-proton-11-arm64", "proton-cachyos-11.0-20261005-slr-arm64")) installTool(tools, name) + File(tools, "DroidDeck-Proton 11-6").mkdirs() // no manifest: half unpacked, not installed + assertEquals(listOf("GE-Proton11-9", "GE-Proton11-10"), ProtonExtras.installedIn(tools, tool("ge"))) + // Ours, not DroidDeck's own droiddeck-proton-* tools: the prefix is matched with its case. + assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedIn(tools, tool("droiddeck"))) + assertEquals(listOf("proton-cachyos-11.0-20261005-slr-arm64"), ProtonExtras.installedIn(tools, tool("cachyos"))) + assertEquals(emptyList(), ProtonExtras.installedIn(File(tools, "missing"), tool("ge"))) + } + + @Test + fun anUpdateIsANewerReleaseThanTheNewestInstalledBuild() { + assertEquals("DroidDeck-Proton-11-7.6", ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5"), "DroidDeck-Proton-11-7.6")) + assertEquals("DroidDeck-Proton-11-7.10", ProtonExtras.updateFor(listOf("DroidDeck-Proton-11-7.9"), "DroidDeck-Proton-11-7.10")) + assertNull(ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5"), "DroidDeck-Proton-11-7.5")) + assertNull(ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton-11-7.6"), "DroidDeck-Proton-11-7.6")) + assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-10"), "GE-Proton11-9")) + assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-7-aarch64"), "GE-Proton11-7")) + assertEquals("GE-Proton11-10", ProtonExtras.updateFor(listOf("GE-Proton11-7-aarch64", "GE-Proton11-9"), "GE-Proton11-10")) + assertEquals("cachyos-11.0-20261101-slr", ProtonExtras.updateFor(listOf("proton-cachyos-11.0-20261005-slr-arm64"), "cachyos-11.0-20261101-slr")) + assertNull(ProtonExtras.updateFor(listOf("proton-cachyos-11.0-20261005-slr-arm64"), "cachyos-10.0-sunset-slr")) + assertNull(ProtonExtras.updateFor(emptyList(), "GE-Proton11-10")) + assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-7"), null)) + } + + @Test + fun aRecentCheckIsReusedAndComparedWithTheInstalledBuild() { + val context = RuntimeEnvironment.getApplication() + val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d") + installTool(tools, "DroidDeck-Proton 11-7.5") + installTool(tools, "GE-Proton11-7") + val now = 1_800_000_000_000L + val latest = JSONObject() + .put("ge", JSONObject().put("tag", "GE-Proton11-7").put("at", now - 60_000)) + .put("cachyos", JSONObject().put("tag", "cachyos-11.0-20261005-slr").put("at", now - 60_000)) + .put("droiddeck", JSONObject().put("tag", "DroidDeck-Proton-11-7.6").put("at", now - ProtonExtras.CHECK_EVERY_MS + 60_000)) + File(context.filesDir, "proton-latest.json").writeText(latest.toString()) + // Every source was looked up within the last six hours: nothing goes to GitHub. + assertFalse(ProtonExtras.checkLatest(context, now = now)) + assertEquals("DroidDeck-Proton-11-7.6", ProtonExtras.update(context, tool("droiddeck"))) + assertNull(ProtonExtras.update(context, tool("ge"))) + assertNull(ProtonExtras.update(context, tool("cachyos"))) + assertEquals(listOf("DroidDeck-Proton 11-7.5"), ProtonExtras.installedTags(context, tool("droiddeck"))) + // The tag recorded at install stands for the folder. + File(context.filesDir, "proton-extras.json").writeText(JSONObject().put("DroidDeck-Proton 11-7.5", "DroidDeck-Proton-11-7.6").toString()) + assertEquals(listOf("DroidDeck-Proton-11-7.6"), ProtonExtras.installedTags(context, tool("droiddeck"))) + assertNull(ProtonExtras.update(context, tool("droiddeck"))) + } + + @Test + fun removingDeletesOneBuildOrEveryOlderOne() { + val context = RuntimeEnvironment.getApplication() + val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d") + for (name in listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.6", "DroidDeck-Proton 11-7.10", "GE-Proton11-7")) installTool(tools, name) + ProtonExtras.remove(context, tool("droiddeck"), "DroidDeck-Proton 11-7.6") + assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck"))) + // A folder of another source is never removed through this one. + ProtonExtras.remove(context, tool("droiddeck"), "GE-Proton11-7") + assertTrue(File(tools, "GE-Proton11-7").isDirectory) + assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck"))) + ProtonExtras.removeOlder(context, tool("droiddeck")) + assertEquals(listOf("DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck"))) + assertEquals("DroidDeck-Proton 11-7.10", ProtonExtras.installed(context, tool("droiddeck"))) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra index 415e49a18..68997cc5e 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra @@ -4,14 +4,15 @@ # # droiddeck-proton-extra ge [--tag GE-Proton11-7] # droiddeck-proton-extra cachyos [--tag cachyos-11.0-20260703-slr] +# droiddeck-proton-extra droiddeck [--tag DroidDeck-Proton-11-7.5] # droiddeck-proton-extra # droiddeck-proton-extra /path/to/an/aarch64/tarball # droiddeck-proton-extra --list what is installed now # -# Both projects publish native ARM64 builds - GloriousEggroll as "*-aarch64.tar.gz", CachyOS as -# "*-arm64.tar.xz" - and both are ordinary Proton trees: a python entry point beside a files/ -# directory holding Wine, DXVK and VKD3D. That is the same shape as the depot the Steam client -# downloads, so the runtime can run them the same way. +# All three projects publish native ARM64 builds - GloriousEggroll and DroidDeck-Proton as +# "*-aarch64.tar.gz", CachyOS as "*-arm64.tar.xz" - and all are ordinary Proton trees: a python +# entry point beside a files/ directory holding Wine, DXVK and VKD3D. That is the same shape as the +# depot the Steam client downloads, so the runtime can run them the same way. # # What it cannot use is their toolmanifest. Like Valve's ARM64 depot they declare # require_tool_appid, which makes Steam stack pressure-vessel underneath - and pressure-vessel @@ -30,11 +31,12 @@ import urllib.request RELEASES = "https://api.github.com/repos/%s/releases" # Each source: the GitHub repo, and how its ARM64 asset is named. Ordered newest-release-first by -# the API, so "latest usable" is the first release that actually carries an ARM64 asset - the +# the API, so "latest usable" is the first full release that actually carries an ARM64 asset - the # projects do not build one for every tag. SOURCES = { "ge": ("GloriousEggroll/proton-ge-custom", re.compile(r"aarch64\.tar\.(gz|xz)$")), "cachyos": ("CachyOS/proton-cachyos", re.compile(r"arm64\.tar\.(gz|xz)$")), + "droiddeck": ("The412Banner/DroidDeck-Proton", re.compile(r"aarch64\.tar\.(gz|xz)$")), } USER_AGENT = "droiddeck-proton-extra" # Room for the tarball and the tree it unpacks to. GE-Proton's aarch64 build is ~650 MB packed and @@ -54,10 +56,10 @@ def fetch_json(url): def pick_asset(source): - """(release tag, asset url, sha512 url or None) for the newest release with an ARM64 build.""" + """(release tag, asset url, sha512 url or None) for the newest full release with an ARM64 build.""" repo, pattern = SOURCES[source] for release in fetch_json(RELEASES % repo + "?per_page=15"): - if release.get("draft"): + if release.get("draft") or release.get("prerelease"): continue assets = {a["name"]: a["browser_download_url"] for a in release.get("assets", ())} for name in sorted(assets): @@ -144,7 +146,7 @@ def installed(tools): def main(): argv = sys.argv[1:] if not argv: - raise SystemExit(__doc__ or "usage: droiddeck-proton-extra ge|cachyos|") + raise SystemExit(__doc__ or "usage: droiddeck-proton-extra ge|cachyos|droiddeck|") steam_root = argv.pop(0) tools = os.path.join(steam_root, "compatibilitytools.d") os.makedirs(tools, exist_ok=True) @@ -176,7 +178,7 @@ def main(): log("using %s" % what) else: raise SystemExit( - "droiddeck-proton-extra: expected ge, cachyos, a URL or a tarball path, got %r" % what) + "droiddeck-proton-extra: expected ge, cachyos, droiddeck, a URL or a tarball path, got %r" % what) free = shutil.disk_usage(tools).free if free < NEED_BYTES: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 94d9a6937..b06664260 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -376,9 +376,9 @@ case $mode in # its main library already - and the ARM64 Proton that lets it launch the Windows ones. # A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts, # so it is in the Compatibility dropdown the first time the user looks. One per line, each the - # arguments to droiddeck-proton-extra: "ge", "cachyos", "ge --tag GE-Proton11-7", or a URL. - # Hundreds of megabytes each, so it is only ever done on request, and the line is dropped once - # it has succeeded - a failed download stays and is retried next session. + # arguments to droiddeck-proton-extra: "ge", "cachyos", "droiddeck", "ge --tag GE-Proton11-7", + # or a URL. Hundreds of megabytes each, so it is only ever done on request, and the line is + # dropped once it has succeeded - a failed download stays and is retried next session. if [ -s "$HOME/.bl-proton-extra" ]; then _bl_left=$HOME/.bl-proton-extra.left : > "$_bl_left" diff --git a/tools/tests/test_esync_packs.py b/tools/tests/test_esync_packs.py index a793f1bee..e836eaad1 100644 --- a/tools/tests/test_esync_packs.py +++ b/tools/tests/test_esync_packs.py @@ -746,6 +746,16 @@ class SelectTest(SyncTestCase): self.assertFalse((root / "wanted.tsv").exists()) self.assertFalse((root / "dist").exists()) + def test_droiddeck_proton_has_esync_built_in(self): + root = self.store() + tool = make_tool(self.tools, "DroidDeck-Proton 11-7.5", "1791000000 DroidDeck-Proton-11-7.5", builtin=True, ge=True, fsync=True) + command, env, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertNotIn("BL_SYNC_PACK", env) + self.assertEqual(said, "droiddeck-esync: DroidDeck-Proton 11-7.5 has esync built in\n") + self.assertFalse((root / "wanted.tsv").exists()) + self.assertFalse((root / "dist").exists()) + def test_wanted_rows_are_deduplicated(self): root = self.store() tool = make_tool(self.tools, "GE-Proton11-7", GE_LINE, ge=True) @@ -906,6 +916,15 @@ class ReconcileTest(SyncTestCase): self.assertTrue(self.dist_of("ge-GE-Proton11-7-r1", ge).is_dir()) self.assertIn("ge-GE-Proton11-7-r1", (root / "tools.tsv").read_text()) + def test_droiddeck_proton_is_scanned_but_our_own_tools_are_not(self): + steam = self.tmp / "steam" + tools = steam / "compatibilitytools.d" + ours = make_tool(tools, "DroidDeck-Proton 11-7.5", "1791000000 DroidDeck-Proton-11-7.5", salt=b"dd", builtin=True, ge=True) + make_tool(tools, "droiddeck-proton-arm64", salt=b"own") + make_tool(tools, "droiddeck-proton-11-arm64", salt=b"own11") + with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(self.tmp / "sd")}): + self.assertEqual(SYNC["find_tools"](str(steam)), [str(ours)]) + def test_steam_compatibility_reports_sync(self): steam = self.tmp / "steam" make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)") diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 1fa4cd739..817038c7a 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -257,6 +257,40 @@ class GameEnvironmentTest(unittest.TestCase): self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text()) self.assertIn("GE-Proton11-7", [entry["name"] for entry in COMPAT["tool_catalog"](str(home), None)]) + def test_droiddeck_proton_is_adopted_and_labelled_like_any_other_extra(self): + # Its folder has a space ("DroidDeck-Proton 11-7.5") and its internal name starts with + # "DroidDeck-Proton-", which lower-cases to our own tools' prefix: neither may matter. + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + (home / "label").write_text("Compatible\n") + COMPAT["display_name"].__globals__["LABEL_FILE"] = str(home / "label") + tools = home / "compatibilitytools.d" + extra = tools / "DroidDeck-Proton 11-7.5" + extra.mkdir(parents=True) + (extra / "proton").write_text("") + (extra / "toolmanifest.vdf").write_text('"manifest" { "version" "2" "commandline" "/proton %verb%" "require_tool_appid" "4185400" }') + (extra / "compatibilitytool.vdf").write_text( + '"compatibilitytools" { "compat_tools" { "DroidDeck-Proton-11-7.5" { "install_path" "." ' + '"display_name" "DroidDeck-Proton 11-7.5" "from_oslist" "windows" "to_oslist" "linux" } } }') + COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), None) + protect = COMPAT["adopt_extras"](str(tools)) + self.assertEqual(protect, {"DroidDeck-Proton-11-7.5": "DroidDeck-Proton 11-7.5"}) + compat = (extra / "compatibilitytool.vdf").read_text() + self.assertIn('"DroidDeck-Proton-11-7.5"', compat) + self.assertIn('"display_name" "DroidDeck-Proton 11-7.5 (Compatible)"', compat) + self.assertNotIn("require_tool_appid", (extra / "toolmanifest.vdf").read_text()) + self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text()) + catalog = COMPAT["tool_catalog"](str(home), None) + self.assertIn({"name": "DroidDeck-Proton-11-7.5", "display": "DroidDeck-Proton 11-7.5 (Compatible)", + "dir": "DroidDeck-Proton 11-7.5", "valve": False}, catalog) + config = home / "config.vdf" + config.write_text(config_text([("0", COMPAT["TOOL"]), ("42", "DroidDeck-Proton-11-7.5")])) + COMPAT["register_default"](str(config), ["42", "43"], protect=protect) + self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL"], "42": "DroidDeck-Proton-11-7.5", "43": COMPAT["TOOL"]}) + # Once removed it is no longer runnable, and its games go back to the default. + changes, _ = COMPAT["plan_mapping"]({"0": COMPAT["TOOL"], "42": "DroidDeck-Proton-11-7.5"}, ["42"], [COMPAT["TOOL"]], COMPAT["TOOL"], {}) + self.assertEqual(changes, {"42": COMPAT["TOOL"]}) + def test_both_proton_wrappers_call_environment_launcher(self): for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"): subprocess.run(["bash", "-n"], input=script, text=True, check=True) diff --git a/tools/tests/test_proton_extra.py b/tools/tests/test_proton_extra.py new file mode 100644 index 000000000..dab3e2c95 --- /dev/null +++ b/tools/tests/test_proton_extra.py @@ -0,0 +1,107 @@ +import contextlib +import hashlib +import io +import os +from pathlib import Path +import runpy +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +EXTRA = runpy.run_path(str(BIN / "droiddeck-proton-extra")) +GLOBALS = EXTRA["pick_asset"].__globals__ + + +def asset(name): + return {"name": name, "browser_download_url": "https://github.com/o/r/releases/download/t/" + name} + + +def droiddeck_release(ver, prerelease=False, draft=False): + stem = "DroidDeck-Proton-%s-aarch64" % ver + return {"tag_name": "DroidDeck-Proton-" + ver, "prerelease": prerelease, "draft": draft, + "assets": [asset(stem + ".build-info.json"), asset(stem + ".sha512sum"), asset(stem + ".tar.gz")]} + + +class ProtonExtraTest(unittest.TestCase): + def test_sources_and_their_arm64_assets(self): + self.assertEqual(list(EXTRA["SOURCES"]), ["ge", "cachyos", "droiddeck"]) + repo, pattern = EXTRA["SOURCES"]["droiddeck"] + self.assertEqual(repo, "The412Banner/DroidDeck-Proton") + self.assertTrue(pattern.search("DroidDeck-Proton-11-7.5-aarch64.tar.gz")) + self.assertTrue(pattern.search("DroidDeck-Proton-11-8-aarch64.tar.xz")) + self.assertFalse(pattern.search("DroidDeck-Proton-11-7.5-aarch64.sha512sum")) + self.assertFalse(pattern.search("DroidDeck-Proton-11-7.5-aarch64.build-info.json")) + + def test_the_newest_full_release_with_its_sha512_file(self): + releases = [droiddeck_release("11-8", prerelease=True), droiddeck_release("11-7.9", draft=True), droiddeck_release("11-7.5")] + with mock.patch.dict(GLOBALS, {"fetch_json": lambda url: releases}): + tag, url, sums = EXTRA["pick_asset"]("droiddeck") + self.assertEqual(tag, "DroidDeck-Proton-11-7.5") + self.assertTrue(url.endswith("/DroidDeck-Proton-11-7.5-aarch64.tar.gz")) + self.assertTrue(sums.endswith("/DroidDeck-Proton-11-7.5-aarch64.sha512sum")) + with mock.patch.dict(GLOBALS, {"fetch_json": lambda url: [droiddeck_release("11-8", prerelease=True)]}): + self.assertEqual(EXTRA["pick_asset"]("droiddeck"), (None, None, None)) + + def test_a_tagged_release_is_fetched_by_its_tag(self): + asked = [] + with mock.patch.dict(GLOBALS, {"fetch_json": lambda url: asked.append(url) or droiddeck_release("11-7.5")}): + tag, url, sums = EXTRA["release_asset"]("droiddeck", "DroidDeck-Proton-11-7.5") + self.assertEqual(asked, ["https://api.github.com/repos/The412Banner/DroidDeck-Proton/releases/tags/DroidDeck-Proton-11-7.5"]) + self.assertTrue(url.endswith(".tar.gz") and sums.endswith(".sha512sum")) + + def test_a_ge_style_sha512_file_verifies_the_download(self): + with tempfile.TemporaryDirectory() as tmp: + tarball = Path(tmp) / "DroidDeck-Proton-11-7.5-aarch64.tar.gz" + tarball.write_bytes(b"tarball") + line = "%s %s\n" % (hashlib.sha512(b"tarball").hexdigest(), tarball.name) + + class Reply(io.BytesIO): + def __exit__(self, *exc): + return False + + with mock.patch.object(GLOBALS["urllib"].request, "urlopen", lambda req, timeout: Reply(line.encode())), \ + contextlib.redirect_stdout(io.StringIO()) as said: + EXTRA["verify"](str(tarball), "https://example/sums") + self.assertIn("checksum ok", said.getvalue()) + self.assertTrue(tarball.exists()) + bad = "%s %s\n" % ("0" * 128, tarball.name) + with mock.patch.object(GLOBALS["urllib"].request, "urlopen", lambda req, timeout: Reply(bad.encode())), \ + contextlib.redirect_stdout(io.StringIO()), self.assertRaises(SystemExit): + EXTRA["verify"](str(tarball), "https://example/sums") + self.assertFalse(tarball.exists()) + + def test_a_top_folder_with_a_space_is_installed_under_its_own_name(self): + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "src" / "DroidDeck-Proton 11-7.5" + (src / "files/bin").mkdir(parents=True) + (src / "proton").write_text("#!/usr/bin/python3\n") + (src / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }') + (src / "compatibilitytool.vdf").write_text( + '"compatibilitytools" { "compat_tools" { "DroidDeck-Proton-11-7.5" { "install_path" "." ' + '"display_name" "DroidDeck-Proton 11-7.5" } } }') + (src / "version").write_text("1791000000 DroidDeck-Proton-11-7.5\n") + tarball = Path(tmp) / "DroidDeck-Proton-11-7.5-aarch64.tar.gz" + with tarfile.open(tarball, "w:gz") as tar: + tar.add(src, arcname=src.name) + steam = Path(tmp) / "Steam" + adopted = [] + real = subprocess.call + # tar runs for real; steam-compatibility (the adoption step) is only recorded. + fake = lambda args, **kw: adopted.append(args) or 0 if args[0] == "steam-compatibility" else real(args, **kw) + with mock.patch.object(GLOBALS["subprocess"], "call", fake), contextlib.redirect_stdout(io.StringIO()) as said: + with mock.patch.object(GLOBALS["sys"], "argv", ["droiddeck-proton-extra", str(steam), str(tarball)]): + EXTRA["main"]() + tools = steam / "compatibilitytools.d" + self.assertEqual(sorted(os.listdir(tools)), [".droiddeck-download", "DroidDeck-Proton 11-7.5"]) + self.assertTrue((tools / "DroidDeck-Proton 11-7.5" / "toolmanifest.vdf").is_file()) + self.assertEqual(adopted, [["steam-compatibility", str(steam)]]) + # The app reads the folder from this line, space and all. + self.assertIn("droiddeck-proton-extra: installed DroidDeck-Proton 11-7.5\n", said.getvalue()) + self.assertEqual(EXTRA["installed"](str(tools)), ["DroidDeck-Proton 11-7.5"]) + + +if __name__ == "__main__": + unittest.main() From 3c2878cc522a917af17581d60de33c591b002738 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 11 Oct 2026 15:53:01 -0400 Subject: [PATCH 3/6] Red Dead Redemption 2: launches again, no screensaver over games, hidden launcher held to one core, /tmp cleaned (#585) * fix: Red Dead Redemption 2 launches again, its Vulkan runtime installer is left to run droiddeck-seed-redists marked every process in every game's install script as already run, Red Dead Redemption 2's VulkanRT-1.1.108.0 installer included. Steam's compat config for the game says nativevulkanloader, so Proton loads vulkan-1.dll native only, and Khronos' loader is in the prefix only once that installer has run. Without it the Rockstar launcher starts RDR2.exe and it exits with STATUS_DLL_NOT_FOUND (0xc0000135) before it opens a window. A Vulkan runtime installer is now left out of the seeding, and a marker an earlier seeding wrote for one comes out of each prefix and Proton template once (dates kept), so the client runs the installer on the next launch. The installer finishes in a few seconds under FEX. * fix: Steam's screensaver no longer covers a running game The client's defaults show screensavers while a game runs, after 300 s on AC power without a button press - and a held stick or trigger is no press. Red Dead Redemption 2's intro runs longer than that: the slideshow covered the game, and the button that woke it reached the game too (B asked to quit to the desktop). Customization/Screensaver/WhileInGame is seeded "0" in config.vdf once, like the UI scale and native resolution, so turning it back on in Settings stays the user's choice. On the RedMagic: 18 minutes from the last button press through the intro and riding, focus never left the game and no screensaver was created. * fix: Rockstar's hidden launcher no longer takes cores from its game The Rockstar Games Launcher keeps drawing its Chromium interface (SocialClubHelper.exe: renderers, and a GPU process compositing through DXVK) for as long as the game runs, hidden behind it - and Wine passes Chromium's priorities on, so those threads ran above the game's (the GPU process at nice -10), across every core. With Red Dead Redemption 2 in front that was 1.3 to 2.5 cores under FEX for a page nobody sees. droiddeck-launcher-cores follows gamescope's focus from the agent's focus.json: while a game window has it, every SocialClubHelper thread runs on the last core of the slowest cluster at nice 19; when the launcher's own window has focus again each thread gets its own mask and priority back (also on exit). Stopping the helpers would not hold - proot resumes a stopped tracee. Started for Steam sessions; BL_LAUNCHER_CORES=0 turns it off. RedMagic, RDR2 all Low, riding at the thermal limit, same spot and session: released 46.5 fps, 1% low 27.5, p99 31.8 ms, CPU 89%, 2200 MHz; held 48.7 fps, 1% low 32.6, p99 27.6 ms, CPU 79%, 2638 MHz. * fix: sessions clean up what earlier ones left in /tmp The guest's /tmp is a directory in the rootfs that no boot ever empties, and Android ends sessions by killing them. Every session left a /tmp/mangohud.XXXXXXXX (mktemp, never removed), and the Steam client, its web helper, Chromium apps, dbus-run-session, AppImage runs and KDE their own temporary files and sockets: 1,422 entries on the RedMagic. mangoapp's config is now one fixed file rewritten each session, and the session's first pass sweeps each kind of leftover while nothing that could own one is running, with a single find. On the device that cleared 1,391 entries over two sessions, leaving 38. --- .../launcher/session/SessionFiles.kt | 1 + .../overlay/usr/local/bin/droiddeck-game-env | 5 +- .../usr/local/bin/droiddeck-launcher-cores | 230 ++++++++++++++++++ .../usr/local/bin/droiddeck-seed-redists | 72 +++++- .../overlay/usr/local/bin/droiddeck-session | 41 +++- .../usr/local/bin/droiddeck-steam-ui-scale | 98 +++++--- tools/tests/test_launcher_cores.py | 220 +++++++++++++++++ tools/tests/test_seed_redists.py | 63 +++++ tools/tests/test_session_tmp.py | 95 ++++++++ tools/tests/test_steam_ui_scale.py | 59 +++++ 10 files changed, 841 insertions(+), 43 deletions(-) create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-launcher-cores create mode 100644 tools/tests/test_launcher_cores.py create mode 100644 tools/tests/test_session_tmp.py diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index f69e4c837..9bc9fc195 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -72,6 +72,7 @@ object SessionFiles { "usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui", "usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library", "usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists", + "usr/local/bin/droiddeck-launcher-cores" to "usr/local/bin/droiddeck-launcher-cores", "usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra", "usr/local/bin/droiddeck-netmanager" to "usr/local/bin/droiddeck-netmanager", "usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1", diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 067212e34..792a7676a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -255,8 +255,9 @@ EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGN def mark_install_scripts(command, env): """The client is about to run a game's install scripts (iscriptevaluator.exe): mark every process - in them as already run first, so no redistributable installer starts - on first launch the - shared ones may still be downloading, and on ARM64 Proton the VC++ ones stop with an error.""" + in them but a Vulkan runtime installer as already run first, so no redistributable installer + starts - on first launch the shared ones may still be downloading, and on ARM64 Proton the VC++ + ones stop with an error.""" if not any(arg.lower().replace("\\", "/").endswith("iscriptevaluator.exe") for arg in command): return root = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-launcher-cores b/tools/linuxfs/overlay/usr/local/bin/droiddeck-launcher-cores new file mode 100755 index 000000000..dad3d1033 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-launcher-cores @@ -0,0 +1,230 @@ +#!/usr/bin/python3 +"""Holds a game launcher's hidden web interface on one slow core while something else has focus. + +Rockstar's launcher keeps drawing its Chromium interface (SocialClubHelper.exe: a renderer, and a +GPU process compositing through DXVK) for as long as it runs, hidden behind the game too, and at a +higher priority than the game's (Wine passes Chromium's on: the GPU process ran at nice -10). With +Red Dead Redemption 2 in front on the RedMagic that was 1.3 to 2.5 cores under FEX, on a phone +already held back by its temperature, for a page nobody sees. Held, riding at the throttle limit +went from 46.5 to 48.7 fps, 1% lows from 27.5 to 32.6. So while the focused window belongs to +anything but the launcher, every helper thread runs on one core of the slowest cluster at nice 19; +when the launcher's own window has focus again (signing in, the game closed) each thread gets its +own mask and priority back. Stopping the helpers instead would not hold: proot, the tracer of every +guest process, resumes a stopped tracee. + +Focus comes from droiddeck-agent's focus.json (the path is the argument), which the agent rewrites +whenever gamescope's focus changes. droiddeck-session starts this for Steam sessions; +BL_LAUNCHER_CORES=0 leaves the launchers alone.""" +import json +import os +import signal +import sys +import time +from pathlib import Path + +# Image paths (lower case, backslashes), matched at the end: the helpers held back, and the +# programs whose window having focus means the user is looking at the launcher. +HELPERS = ("\\rockstar games\\social club\\socialclubhelper.exe",) +FRONTS = HELPERS + ("\\rockstar games\\launcher\\launcher.exe",) +NICE = 19 +POLL = 2.0 +# How often a held launcher is looked over again: for a helper the unheld Launcher.exe started +# since, and for threads the program has moved back. +RESCAN = 10.0 +STEAM_CLIENT = 769 + + +def log(message): + print("droiddeck-launcher-cores: " + message, flush=True) + + +def image(proc, pid): + """A process's program as Wine names it (argv[0]), lower case with backslashes, or None.""" + try: + with open(proc / str(pid) / "cmdline", "rb") as stream: + first = stream.read(4096).split(b"\0", 1)[0] + except OSError: + return None + return first.decode("utf-8", "replace").lower().replace("/", "\\") + + +def is_one_of(path, suffixes): + return path is not None and path.endswith(suffixes) + + +def tasks(proc, pid): + try: + return [int(tid) for tid in os.listdir(proc / str(pid) / "task") if tid.isdigit()] + except OSError: + return [] + + +class Cores: + """The kernel calls, apart so a test can stand in for them.""" + + def own(self): + return os.sched_getaffinity(0) + + def get(self, tid): + return os.sched_getaffinity(tid), os.getpriority(os.PRIO_PROCESS, tid) + + def set(self, tid, cpus, nice): + try: + os.sched_setaffinity(tid, cpus) + except OSError: + # A mask outside the app's cpuset now (Android shrinks it in the background) is + # EINVAL; the session's own is always allowed. + os.sched_setaffinity(tid, self.own()) + os.setpriority(os.PRIO_PROCESS, tid, nice) + + +class Holder: + def __init__(self, proc=Path("/proc"), cpufreq=Path("/sys/devices/system/cpu"), cores=None): + self.proc = proc + self.cpufreq = cpufreq + self.cores = cores or Cores() + self.held = {} # pid -> {tid: (cpus, nice) before the hold} + self.core = None + + def slow_core(self): + """The last core of the slowest cluster the session has - not of the game's cores, which + the launcher inherits when BL_GAME_CPUS is set: the core the game misses least.""" + def top(cpu): + try: + return int((self.cpufreq / f"cpu{cpu}/cpufreq/cpuinfo_max_freq").read_text()) + except (OSError, ValueError): + return 0 + cpus = self.cores.own() + slowest = min(top(cpu) for cpu in cpus) + return max(cpu for cpu in cpus if top(cpu) == slowest) + + def helpers(self): + for name in os.listdir(self.proc): + if name.isdigit() and is_one_of(image(self.proc, name), HELPERS): + yield int(name) + + def hold(self): + """Every helper thread onto the slow core at NICE: new ones, and held ones the program has + moved since (Chromium sets its threads' priorities, and Wine passes that on).""" + if self.core is None: + self.core = self.slow_core() + hold = ({self.core}, NICE) + for pid in self.helpers(): + before = self.held.setdefault(pid, {}) + for tid in tasks(self.proc, pid): + try: + now = self.cores.get(tid) + if now != hold: + self.cores.set(tid, *hold) + except OSError: + continue # the thread ended + if tid not in before: + # A thread that already has the hold inherited it from a held parent: it gets + # what its process had, or the helpers' first. + before[tid] = now if now != hold else before.get(pid) or self.first() or now + for pid in [pid for pid in self.held if not (self.proc / str(pid)).exists()]: + del self.held[pid] + + def first(self): + for before in self.held.values(): + for state in before.values(): + if state != ({self.core}, NICE): + return state + return None + + def release(self): + """Every held thread back to its own mask and priority; one started during the hold gets + what its process had.""" + for pid, before in self.held.items(): + default = before.get(pid) or self.first() + for tid in tasks(self.proc, pid): + state = before.get(tid, default) + if state is None: + continue + try: + self.cores.set(tid, *state) + except OSError: + pass + self.held.clear() + + +def focused(snapshot): + """(app id, pid) of the window gamescope has focused, from droiddeck-agent's snapshot.""" + window = snapshot.get("focusedWindow") + for entry in snapshot.get("focusableWindows") or []: + if entry.get("window") == window: + return snapshot.get("focusedApp"), entry.get("pid") + return snapshot.get("focusedApp"), None + + +class Governor: + def __init__(self, focus_path, holder=None, clock=time.monotonic): + self.focus_path = Path(focus_path) + self.holder = holder or Holder() + self.clock = clock + self.seen = None + self.app = self.pid = None + self.scanned = None + self.reported = None + + def read_focus(self): + try: + stamp = self.focus_path.stat().st_mtime_ns + except OSError: + return False + if stamp == self.seen: + return False + try: + self.app, self.pid = focused(json.loads(self.focus_path.read_text())) + except (OSError, ValueError, AttributeError): + return False + self.seen = stamp + return True + + def tick(self): + changed = self.read_focus() + if self.pid is None: + return + launcher_in_front = is_one_of(image(self.holder.proc, self.pid), FRONTS) + if launcher_in_front: + if self.holder.held: + self.holder.release() + self.report("released: the launcher has focus") + return + # Nothing to look for until a game has had focus: launchers only run for one, and a launcher + # left running after its game stays held through the Steam client's own focus. + if self.app in (None, 0, STEAM_CLIENT) and not self.holder.held: + return + now = self.clock() + if changed or self.scanned is None or now - self.scanned >= RESCAN: + self.scanned = now + self.holder.hold() + if self.holder.held: + self.report(f"{len(self.holder.held)} SocialClubHelper process(es) on core {self.holder.core} " + f"at nice {NICE} while the launcher is hidden") + + def report(self, message): + if message != self.reported: + self.reported = message + log(message) + + def stop(self, *_): + self.holder.release() + sys.exit(0) + + +def main(argv): + if len(argv) != 1: + print("usage: droiddeck-launcher-cores ", file=sys.stderr) + return 2 + governor = Governor(argv[0]) + signal.signal(signal.SIGTERM, governor.stop) + signal.signal(signal.SIGHUP, governor.stop) + signal.signal(signal.SIGINT, governor.stop) + while True: + governor.tick() + time.sleep(POLL) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists index 7b430422a..053df0e93 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists @@ -17,6 +17,11 @@ # 2015-2022 runtime is recorded as installed the way Microsoft's installers look for it: on ARM64 # Proton the x64 bundle otherwise stops with "Microsoft Visual C++ 2015-2022 Redistributable (arm64) # is required", while Proton's own msvcp140/vcruntime140 already serve the game. +# +# A Vulkan runtime installer is the one left to run. Proton has no stand-in for it: for a game whose +# Steam compat config says nativevulkanloader (Red Dead Redemption 2) it loads vulkan-1.dll native +# only, and Khronos' loader is in the prefix only once that installer has run - without it the game +# exits with STATUS_DLL_NOT_FOUND before it opens a window. The installer finishes in a second. set -u steam_root=${1:?steam root} LIBRARIES=${DROIDDECK_LIBRARIES-/mnt/droiddeck /mnt/droiddeck-sd} @@ -25,14 +30,15 @@ redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist" # Every process an install script would run, as the registry key and value the client writes once # it has run it. Names sit one level inside "Run Process", the key they share a level further in. -read_scripts() { xargs -0 -r awk ' +# "read_scripts vulkan" lists only the Vulkan runtime installers, which the plain call leaves out. +read_scripts() { xargs -0 -r awk -v want="${1:-redists}" ' # A block whose HasRunKey is set is one process; its name is the key the block opened under, at # whatever depth - an install script nests it under "Run Process", the client'"'"'s evaluator script # for a game wraps the same blocks once more. FNR == 1 { depth = 0; pending = "" } - /^[[:space:]]*\{/ { depth++; name[depth] = pending; key[depth] = ""; pending = ""; next } + /^[[:space:]]*\{/ { depth++; name[depth] = pending; key[depth] = ""; vulkan[depth] = 0; pending = ""; next } /^[[:space:]]*\}/ { - if (depth > 0 && key[depth] != "" && name[depth] != "") + if (depth > 0 && key[depth] != "" && name[depth] != "" && vulkan[depth] == (want == "vulkan")) printf "%s\t%s\n", key[depth], name[depth] if (depth > 0) depth-- next @@ -40,6 +46,12 @@ read_scripts() { xargs -0 -r awk ' { n = split($0, part, "\"") if (n >= 5 && tolower(part[2]) == "hasrunkey") { key[depth] = part[4]; next } + if (n >= 5 && tolower(part[2]) ~ /^process [0-9]+$/) { + program = tolower(part[4]) + sub(/.*[\\\/]/, "", program) + if (program ~ /vulkan/) vulkan[depth] = 1 + next + } if (n == 3) pending = part[2] } '; } @@ -84,13 +96,17 @@ if [ "$(head -n 1 "$cache" 2>/dev/null)" != "$sig" ]; then done <<<"$libraries" | sort -u } >"$cache.tmp" && mv -f "$cache.tmp" "$cache" fi -game_pairs=$(tail -n +2 "$cache" 2>/dev/null | tr '\n' '\0' | read_scripts) # DROIDDECK_SEED_SCRIPTS: more scripts, one path a line - droiddeck-game-env passes the client's # evaluator script for the game about to run its installers, read before the client looks. -if [ -n "${DROIDDECK_SEED_SCRIPTS:-}" ]; then - game_pairs=$(printf '%s\n%s\n' "$game_pairs" "$(printf '%s\n' "$DROIDDECK_SEED_SCRIPTS" | tr '\n' '\0' | read_scripts)") -fi +game_scripts() { { tail -n +2 "$cache" 2>/dev/null; printf '%s\n' "${DROIDDECK_SEED_SCRIPTS:-}"; } | sed '/^$/d' | tr '\n' '\0'; } +game_pairs=$(game_scripts | read_scripts) [ -n "$(printf '%s' "$game_pairs" | tr -d '[:space:]')" ] && pairs=$(printf '%s\n%s\n' "$pairs" "$game_pairs" | sort -u | sed '/^$/d') +# A Vulkan runtime installer marked as run by an earlier seeding still has its marker: those come out +# again below, so the client runs the installer on the game's next launch. +declare -A vulkan=() +while IFS= read -r line; do + [[ $line == *$'\t'* ]] && vulkan[$line]=1 +done < <(game_scripts | read_scripts vulkan) # The VC++ 2015-2022 runtime as its installers check for it, for each architecture and both registry # views. The version is the newest the scripts above know, so any 2015-2022 bundle finds itself @@ -138,6 +154,38 @@ block_for() { printf '%s\n' "$@" | awk -F'\t' -v now="$now" ' for line; do [[ $line == vcrt$'\t'* ]] && vcrt "$now"; done } +# Takes the given markers out of a registry file again, in both views, keeping its date. +unseed() { + local reg=$1 out + shift + out=$(mktemp "${reg%/*}/.system.reg.XXXXXX") || return 1 + if printf '%s\n' "$@" | awk -F'\t' ' + NR == FNR { + path = $1 + sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path) + if (path == $1) + next + path = tolower(path) + drop["[" path "]" SUBSEP tolower($2)] = 1 + if (substr(path, 1, 10) == "software\\\\") + drop["[software\\\\wow6432node\\\\" substr(path, 11) "]" SUBSEP tolower($2)] = 1 + next + } + /^\[/ { section = tolower(substr($0, 1, index($0, "]"))) } + /^"/ { + value = substr($0, 2) + value = tolower(substr(value, 1, index(value, "\"=") - 1)) + if ((section SUBSEP value) in drop) + next + } + { print } + ' - "$reg" >"$out" && touch -r "$reg" "$out" && mv -f "$out" "$reg"; then + return 0 + fi + rm -f "$out" + return 1 +} + # Proton builds a new prefix from its template, and the client runs the install scripts in that # prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a # changed one as a new template and upgrades every prefix made from the old. @@ -158,6 +206,16 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste if [ -f "$seeded" ]; then while IFS= read -r line; do [[ $line == *$'\t'* ]] && had[$line]=1; done <"$seeded" fi + stale=() kept=() + for line in "${!had[@]}"; do + if [ -n "${vulkan[$line]:-}" ]; then stale+=("$line"); else kept+=("$line"); fi + done + if [ ${#stale[@]} -gt 0 ] && unseed "$reg" "${stale[@]}"; then + had=() + for line in "${kept[@]}"; do had[$line]=1; done + printf '%s\n' "${kept[@]}" >"$seeded" + echo "== took Vulkan runtime markers out of $reg" + fi missing=() for line in "${want[@]}"; do [ -n "${had[$line]:-}" ] || missing+=("$line"); done [ ${#missing[@]} -eq 0 ] && continue diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 94d9a6937..0e3284a57 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -50,6 +50,37 @@ prepare_locale() { } [ -z "${BL_INSIDE:-}" ] && prepare_locale +# What earlier sessions left in /tmp. The guest's /tmp is a directory in the rootfs, never emptied +# by a boot, and Android ends sessions by killing them: each left its mangoapp config, the client's +# temporary directories and shared-memory sockets, Chromium's singleton directory (the web +# helper's, an Electron app's), a session bus socket per run and a run's start stamp - 1,400 +# entries on a device after a few weeks. Each kind is swept while nothing that could own one runs, +# with one find for the lot. +clear_stale_tmp() { + local running names=() n + running=$(pgrep -af 'steamwebhelper|steamrtarm64/steam|--type=|droiddeck-appimage-run|dbus-run-session|dbus-daemon.*--session|dbus-launch|kwin|plasmashell' 2>/dev/null) + case $running in + *steamwebhelper* | *steamrtarm64/steam*) ;; + *) names+=(-o -name 'mangohud.????????' -o -name '.com.valvesoftware.Steam.??????' + -o -name 'steam_chrome_shmem_uid*' -o \( -type d -empty -name 'steam??????' \)) ;; + esac + case $running in *--type=*) ;; *) names+=(-o \( -type d -name 'scoped_dir??????' \)) ;; esac + case $running in *droiddeck-appimage-run*) ;; *) names+=(-o -name 'droiddeck-appimage.??????') ;; esac + case $running in + *dbus-run-session* | *dbus-daemon*--session* | *dbus-launch*) ;; + *) names+=(-o \( -type s -name 'dbus-??????????' \)) ;; + esac + case $running in + *kwin* | *plasmashell*) ;; + *) names+=(-o \( -type d -empty -name 'plasma-csd-generator.??????' \)) ;; + esac + [ ${#names[@]} -gt 0 ] || return 0 + n=$(find /tmp -mindepth 1 -maxdepth 1 \( "${names[@]:1}" \) -print -exec rm -rf {} + 2>/dev/null | wc -l) + [ "$n" -gt 0 ] && echo "== /tmp: cleared $n entries earlier sessions left" + return 0 +} +[ -z "${BL_INSIDE:-}" ] && clear_stale_tmp + # Until the runtime is stable, Steam's own logs are copied beside the session log at exit so a # failed launch can be handed over as one folder. collect_steam_logs() { @@ -271,7 +302,8 @@ if [ -z "${BL_INSIDE:-}" ]; then # BL_MANGOAPP=0 turns it off (one community build found Valve's mangoapp crashing on Turnip). if [ "$mode" = steam ] && [ "${BL_MANGOAPP:-1}" = 1 ] \ && [ "${BL_STEAM_UI:-}" != desktop ] && command -v mangoapp >/dev/null 2>&1; then - MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX) + # One fixed file, rewritten each session: a mktemp name per session was never removed. + MANGOHUD_CONFIGFILE=/tmp/droiddeck-mangohud.conf echo no_display > "$MANGOHUD_CONFIGFILE" export MANGOHUD_CONFIGFILE STEAM_USE_MANGOAPP=1 STEAM_MANGOAPP_PRESETS_SUPPORTED=1 STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1 args+=(--mangoapp) @@ -434,6 +466,13 @@ case $mode in done ) & fi + # Rockstar's launcher keeps drawing its hidden web interface for as long as a game of theirs + # runs; droiddeck-launcher-cores holds it on one slow core while it is out of sight, from + # gamescope's focus as the agent publishes it. BL_LAUNCHER_CORES=0 leaves launchers alone. + if [ "${BL_LAUNCHER_CORES:-1}" != 0 ] && [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ] \ + && [ "${BL_STEAM_UI:-}" != desktop ] && [ -x /usr/local/bin/droiddeck-launcher-cores ]; then + /usr/local/bin/droiddeck-launcher-cores "$BL_LAUNCH_DIR/agent/focus.json" 2>&1 & + fi step "registering the app's game library with Steam" droiddeck-steam-library "$steam_root" || true diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale index e7f41383f..b3e009e60 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale @@ -1,10 +1,16 @@ #!/usr/bin/python3 -"""Initialize Steam's own Gamescope display settings once; subsequent choices belong to Steam. +"""Initialize Steam's own display settings once; subsequent choices belong to Steam. The UI scale, and native resolution: the client treats our gamescope as an external display and, unless "Enable Native Resolution on External Displays" is on, draws itself at 1920x1080 on any larger output - a 2340x1080 session came up as 16:9 with bars. The session's size is already the -user's Resolution choice, so the client should use all of it.""" +user's Resolution choice, so the client should use all of it. + +And the screensaver over a running game. The client's defaults (steamui.so's settings descriptor) +show screensavers while a game runs, after 300 s on AC power with no button pressed - and a stick or +trigger held is no press. Red Dead Redemption 2's cutscenes run longer than that: the slideshow +covered the game, and the button that woke it reached the game as well (B asked to quit to the +desktop). Customization > Screensaver > "Show screensavers when playing a game" still turns it on.""" import math import os from pathlib import Path @@ -15,6 +21,8 @@ import tempfile DISPLAY = "External: gamescope|||Windowed" NATIVE_RESOLUTION = "EnableSteamNativeExternalResolution" +# screensaver_while_ingame: kept in config.vdf like the Gamescope settings, under this path. +SCREENSAVER_IN_GAME = ("Customization", "Screensaver", "WhileInGame") TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]') @@ -108,25 +116,43 @@ def seed_text(text, scale): return text[:at] + insertion + text[at:], True -def seed_native_resolution(text): +def seed_value(text, path, value): + """InstallConfigStore/ = value, unless the client already has a value there (the + user's), adding only the blocks that are missing.""" root = parse(text).get('InstallConfigStore') if not isinstance(root, Block): raise ValueError("missing InstallConfigStore") - gamescope = root.get('Gamescope') - if gamescope is not None and not isinstance(gamescope, Block): - raise ValueError("invalid Gamescope settings") - if gamescope is not None and gamescope.get(NATIVE_RESOLUTION) is not None: + *sections, name = path + block, depth = root, 1 + while sections: + child = block.get(sections[0]) + if child is None: + break + if not isinstance(child, Block): + raise ValueError(f"invalid {sections[0]} settings") + block, depth = child, depth + 1 + sections.pop(0) + if not sections and block.get(name) is not None: return text, False - entry = f'"{NATIVE_RESOLUTION}" "1"' - if gamescope is not None: - at, indent = gamescope.end, 2 - else: - entry = '"Gamescope"\n{\n' + entry + '\n}' - at, indent = root.end, 1 - insertion = '\n' + '\n'.join('\t' * indent + line for line in entry.splitlines()) + '\n' + lines = [(len(sections), f'"{name}"\t\t"{value}"')] + for level, section in reversed(list(enumerate(sections))): + lines = [(level, f'"{section}"'), (level, '{')] + lines + [(level, '}')] + insertion = ''.join('\t' * (depth + level) + line + '\n' for level, line in lines) + # Before the line the block closes on, when it has one of its own; else beside the brace. + at = text.rfind('\n', 0, block.end) + 1 + if text[at:block.end].strip(): + at, insertion = block.end, '\n' + insertion return text[:at] + insertion + text[at:], True +def seed_native_resolution(text): + return seed_value(text, ('Gamescope', NATIVE_RESOLUTION), '1') + + +def seed_no_screensaver_in_game(text): + return seed_value(text, SCREENSAVER_IN_GAME, '0') + + def atomic_write(path, text): path.parent.mkdir(parents=True, exist_ok=True) fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent) @@ -140,31 +166,36 @@ def atomic_write(path, text): def initialize(steam_root, width, height): + scale = initial_scale(width, height) + return seed_once(steam_root, 'droiddeck-ui-scale-initialized', lambda text: seed_text(text, scale), + f'== steam: initial handheld UI scale {scale:.3f}') + + +def seed_once(steam_root, marker_name, seed, message): + """Applies seed to config.vdf the first time only: the marker keeps a later choice - including + a return to the client's own default, which the client may store by dropping the key - the + user's.""" config = Path(steam_root) / 'config/config.vdf' - marker = config.with_name('droiddeck-ui-scale-initialized') + marker = config.with_name(marker_name) if marker.exists(): return False text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n' - updated, changed = seed_text(text, initial_scale(width, height)) + updated, changed = seed(text) if changed: atomic_write(config, updated) - print(f'== steam: initial handheld UI scale {initial_scale(width, height):.3f}') + print(message) atomic_write(marker, '1\n') return changed def initialize_native_resolution(steam_root): - config = Path(steam_root) / 'config/config.vdf' - marker = config.with_name('droiddeck-native-resolution-initialized') - if marker.exists(): - return False - text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n' - updated, changed = seed_native_resolution(text) - if changed: - atomic_write(config, updated) - print('== steam: native resolution on the session display') - atomic_write(marker, '1\n') - return changed + return seed_once(steam_root, 'droiddeck-native-resolution-initialized', seed_native_resolution, + '== steam: native resolution on the session display') + + +def initialize_screensaver(steam_root): + return seed_once(steam_root, 'droiddeck-screensaver-initialized', seed_no_screensaver_in_game, + '== steam: no screensaver over a running game') if __name__ == '__main__': @@ -174,9 +205,10 @@ if __name__ == '__main__': except (OSError, ValueError, RecursionError) as error: print('== steam: left UI scale unchanged: ' + str(error), file=sys.stderr) status = 1 - try: - initialize_native_resolution(sys.argv[1]) - except (OSError, ValueError, RecursionError) as error: - print('== steam: left native resolution unchanged: ' + str(error), file=sys.stderr) - status = 1 + for what, step in (('native resolution', initialize_native_resolution), ('screensaver', initialize_screensaver)): + try: + step(sys.argv[1]) + except (OSError, ValueError, RecursionError) as error: + print(f'== steam: left {what} unchanged: {error}', file=sys.stderr) + status = 1 sys.exit(status) diff --git a/tools/tests/test_launcher_cores.py b/tools/tests/test_launcher_cores.py new file mode 100644 index 000000000..98656b745 --- /dev/null +++ b/tools/tests/test_launcher_cores.py @@ -0,0 +1,220 @@ +import json +import os +from pathlib import Path +import runpy +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +MODULE = runpy.run_path(str(BIN / 'droiddeck-launcher-cores')) +Holder = MODULE['Holder'] +Governor = MODULE['Governor'] +NICE = MODULE['NICE'] + +HELPER = 'C:\\Program Files\\Rockstar Games\\Social Club\\SocialClubHelper.exe' +LAUNCHER = 'C:\\Program Files\\Rockstar Games\\Launcher\\Launcher.exe' +GAME = 'S:\\steamapps\\common\\Red Dead Redemption 2\\RDR2.exe' +ALL = set(range(8)) + + +class FakeCores: + """Per-thread masks and priorities, with every change recorded.""" + + def __init__(self): + self.state = {} + self.calls = [] + + def own(self): + return set(ALL) + + def get(self, tid): + if tid not in self.state: + raise ProcessLookupError(tid) + return self.state[tid] + + def set(self, tid, cpus, nice): + if tid not in self.state: + raise ProcessLookupError(tid) + self.calls.append((tid, set(cpus), nice)) + self.state[tid] = (set(cpus), nice) + + +class LauncherCoresTest(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + cpufreq = self.root / 'cpu' + for cpu in ALL: # 0-5 one cluster, 6-7 the fast one + (cpufreq / f'cpu{cpu}/cpufreq').mkdir(parents=True) + (cpufreq / f'cpu{cpu}/cpufreq/cpuinfo_max_freq').write_text('3532800\n' if cpu < 6 else '4320000\n') + self.cores = FakeCores() + self.holder = Holder(self.proc, cpufreq, self.cores) + self.focus = self.root / 'focus.json' + self.now = 0.0 + self.governor = Governor(self.focus, self.holder, clock=lambda: self.now) + + def process(self, pid, argv0, threads=(), cpus=ALL, nice=0): + directory = self.proc / str(pid) + (directory / 'task').mkdir(parents=True) + (directory / 'cmdline').write_bytes(argv0.encode() + b'\0--type=renderer\0') + for tid in (pid, *threads): + self.thread(pid, tid, cpus, nice) + + def thread(self, pid, tid, cpus=ALL, nice=0): + (self.proc / str(pid) / 'task' / str(tid)).mkdir() + self.cores.state[tid] = (set(cpus), nice) + + def end(self, pid): + for tid in os.listdir(self.proc / str(pid) / 'task'): + self.cores.state.pop(int(tid), None) + (self.proc / str(pid) / 'task' / tid).rmdir() + (self.proc / str(pid) / 'task').rmdir() + (self.proc / str(pid) / 'cmdline').unlink() + (self.proc / str(pid)).rmdir() + + def focus_on(self, pid, app=1174180): + stamp = getattr(self, 'stamp', 1_000_000_000) + 1_000_000_000 + self.stamp = stamp + self.focus.write_text(json.dumps({ + 'focusedApp': app, 'focusedWindow': 0x200001, + 'focusableWindows': [{'window': 0x100001, 'appId': 769, 'pid': 9}, + {'window': 0x200001, 'appId': app, 'pid': pid}]})) + os.utime(self.focus, ns=(stamp, stamp)) + + def held(self, tid): + return self.cores.state[tid] == ({5}, NICE) + + def test_helpers_held_on_the_slow_clusters_last_core_while_the_game_has_focus(self): + self.process(100, LAUNCHER, (101,)) + self.process(200, HELPER, (201, 202), nice=-2) + self.process(300, HELPER, (301,)) + self.process(400, GAME, (401,)) + self.focus_on(400) + self.governor.tick() + for tid in (200, 201, 202, 300, 301): + self.assertTrue(self.held(tid), tid) + for tid in (100, 101, 400, 401): + self.assertEqual((ALL, 0), self.cores.state[tid]) + + def test_launcher_focus_gives_each_thread_its_own_mask_and_priority_back(self): + self.process(100, LAUNCHER) + self.process(200, HELPER, (201,), cpus={0, 1, 2, 3}, nice=-2) + self.cores.state[201] = ({6, 7}, 4) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + self.assertTrue(self.held(201)) + self.focus_on(100) + self.governor.tick() + self.assertEqual(({0, 1, 2, 3}, -2), self.cores.state[200]) + self.assertEqual(({6, 7}, 4), self.cores.state[201]) + self.assertEqual({}, self.holder.held) + + def test_nothing_is_touched_before_a_game_has_focus(self): + self.process(100, LAUNCHER) + self.process(200, HELPER) + self.process(9, '/root/.local/share/Steam/steamrtarm64/steam') + self.governor.tick() # no focus file yet + self.focus_on(9, app=769) + self.governor.tick() + self.focus_on(100) + self.governor.tick() + self.assertEqual([], self.cores.calls) + + def test_a_launcher_left_after_its_game_stays_held_behind_the_client(self): + self.process(200, HELPER) + self.process(400, GAME) + self.process(9, '/root/.local/share/Steam/steamrtarm64/steam') + self.focus_on(400) + self.governor.tick() + self.end(400) + self.focus_on(9, app=769) + self.governor.tick() + self.assertTrue(self.held(200)) + + def test_a_thread_inheriting_the_hold_is_released_to_what_its_process_had(self): + self.process(200, HELPER, cpus={0, 1, 2, 3, 4, 5}, nice=1) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + # A new thread, and a new helper process the held one started: both born held. + self.thread(200, 203, {5}, NICE) + self.process(500, HELPER, (501,), cpus={5}, nice=NICE) + self.now += 10 + self.governor.tick() + self.focus_on(200) + self.governor.tick() + for tid in (200, 203, 500, 501): + self.assertEqual(({0, 1, 2, 3, 4, 5}, 1), self.cores.state[tid], tid) + + def test_a_held_thread_the_program_moves_back_is_held_again_on_the_next_look(self): + self.process(200, HELPER, (201,)) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + self.cores.state[201] = (ALL, -5) # the program raised a thread's priority itself + self.now += 5 + self.governor.tick() + self.assertFalse(self.held(201)) # not looked at again before the rescan + self.now += 5 + self.governor.tick() + self.assertTrue(self.held(201)) + self.focus_on(200) + self.governor.tick() + self.assertEqual((ALL, 0), self.cores.state[201]) # its first state, not the program's + + def test_a_steady_hold_is_not_rewritten(self): + self.process(200, HELPER, (201,)) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + calls = len(self.cores.calls) + for _ in range(5): + self.now += 10 + self.governor.tick() + self.assertEqual(calls, len(self.cores.calls)) + + def test_threads_that_end_are_skipped_and_ended_helpers_forgotten(self): + self.process(200, HELPER, (201,)) + self.process(300, HELPER) + self.process(400, GAME) + del self.cores.state[201] # ended between the listing and the call + self.focus_on(400) + self.governor.tick() + self.assertNotIn(201, self.holder.held[200]) + self.end(300) + self.now += 10 + self.governor.tick() + self.assertEqual([200], list(self.holder.held)) + + def test_stop_releases(self): + self.process(200, HELPER) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + with self.assertRaises(SystemExit): + self.governor.stop() + self.assertEqual((ALL, 0), self.cores.state[200]) + + def test_unreadable_focus_is_ignored(self): + self.process(200, HELPER) + self.focus.write_text('{"focusedApp": 1174') + self.governor.tick() + self.focus.write_text('[]') + os.utime(self.focus, ns=(5, 5)) + self.governor.tick() + self.assertEqual([], self.cores.calls) + + def test_window_without_a_pid_is_left_alone(self): + self.process(200, HELPER) + self.focus.write_text(json.dumps({'focusedApp': 1174180, 'focusedWindow': 7, 'focusableWindows': []})) + self.governor.tick() + self.assertEqual([], self.cores.calls) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_seed_redists.py b/tools/tests/test_seed_redists.py index abc77ab07..1f9e2ae71 100644 --- a/tools/tests/test_seed_redists.py +++ b/tools/tests/test_seed_redists.py @@ -22,6 +22,30 @@ GAME_SCRIPT = r'''"InstallScript" } ''' +# Red Dead Redemption 2's own script: Proton needs Khronos' vulkan-1.dll for it, which only the +# Vulkan runtime installer puts in the prefix. +VULKAN_SCRIPT = r'''"InstallScript" +{ + "Run Process" + { + "Vulkan111080" + { + "HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\1174180" + "process 1" "%INSTALLDIR%\\Redistributables\\VulkanRT-1.1.108.0-Installer.exe" + "command 1" "/S" + "NoCleanUp" "0" + } + "RockstarLauncher" + { + "HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Rockstar Games\\Steam\\Launcher" + "process 1" "%INSTALLDIR%\\Redistributables\\Rockstar-Games-Launcher.exe" + "command 1" "/s /t" + "NoCleanUp" "0" + } + } +} +''' + class SeedRedistsTest(unittest.TestCase): def setUp(self): @@ -91,6 +115,45 @@ class SeedRedistsTest(unittest.TestCase): self.run_seed() self.assertEqual(int(self.reg.stat().st_mtime), 1_000_000_000) + def rdr2(self): + path = self.steam / "steamapps/common/Red Dead Redemption 2/installscript.vdf" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(VULKAN_SCRIPT) + (self.steam / "steamapps/appmanifest_1174180.acf").write_text('"AppState" { "installdir" "Red Dead Redemption 2" }') + + def test_a_vulkan_runtime_installer_is_left_to_run(self): + self.rdr2() + self.run_seed() + reg = self.reg.read_text() + self.assertNotIn("Vulkan111080", reg) + self.assertIn('"RockstarLauncher"=dword:00000001', reg) + self.assertNotIn("Vulkan111080", (self.reg.parent / ".droiddeck-redists").read_text()) + + def test_a_vulkan_marker_an_earlier_seeding_wrote_comes_out_once(self): + self.rdr2() + marker = "HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180\tVulkan111080" + self.reg.write_text( + "WINE REGISTRY Version 2\n" + "\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791729070\n#time=1dd598d29161f72\n" + '"Other"=dword:00000001\n"Vulkan111080"=dword:00000001\n' + "\n[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791729070\n" + '"Vulkan111080"=dword:00000001\n' + "\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\22380] 1791311644\n" + '"Vulkan111080"=dword:00000001\n') + (self.reg.parent / ".droiddeck-redists").write_text(marker + "\n") + os.utime(self.reg, (1_000_000_000, 1_000_000_000)) + self.run_seed() + reg = self.reg.read_text() + self.assertEqual(reg.count('"Vulkan111080"'), 1, "only the other app's value stays") + self.assertIn('"Other"=dword:00000001', reg) + self.assertNotIn("Vulkan111080", (self.reg.parent / ".droiddeck-redists").read_text()) + self.assertEqual(int(self.reg.stat().st_mtime), 1_000_000_000) + # The client writes the marker itself once the installer has run; that one is left alone. + with self.reg.open("a") as out: + out.write('\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791730000\n"Vulkan111080"=dword:00000001\n') + self.run_seed() + self.assertEqual(self.reg.read_text().count('"Vulkan111080"'), 2) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_session_tmp.py b/tools/tests/test_session_tmp.py new file mode 100644 index 000000000..58607911d --- /dev/null +++ b/tools/tests/test_session_tmp.py @@ -0,0 +1,95 @@ +import os +import re +import shutil +import socket +import subprocess +import tempfile +import unittest +from pathlib import Path + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" + +STEAM = ["mangohud.Ab3dE6gH", ".com.valvesoftware.Steam.x1Y2z3/", "steam_chrome_shmem_uid10480_0", "steamADfAnd/"] +CHROMIUM = ["scoped_dir3yu9Xt/SingletonSocket"] +APPIMAGE = ["droiddeck-appimage.19PoOD"] +BUS = ["dbus-1zWfOP1IaR="] # = a socket +KDE = ["plasma-csd-generator.BVWIWA/"] +STALE = STEAM + CHROMIUM + APPIMAGE + BUS + KDE +KEPT = ["droiddeck-mangohud.conf", ".X11-unix/", "steamworks", "steamABCDEF/inside", "notes.txt", + "mangohud.conf", ".wine-10480/", "dbus-1zWfOP1IaR.log", "dbus-notasocket", + "plasma-csd-generator.CQGZSw/kept"] + + +def sweep_function(tmp): + match = re.search(r"^clear_stale_tmp\(\) \{\n.*?^\}\n", SESSION.read_text(), re.M | re.S) + return match.group(0).replace("find /tmp ", "find %s " % tmp) + + +class SessionTmpTest(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.root, True) + self.tmp = self.root / "tmp" + self.tmp.mkdir() + for name in STALE + KEPT: + path = self.tmp / name + if name.endswith("/"): + path.mkdir(parents=True) + elif name.endswith("="): + listener = socket.socket(socket.AF_UNIX) + listener.bind(str(self.tmp / name[:-1])) + listener.close() + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("x") + self.bin = self.root / "bin" + self.bin.mkdir() + + def sweep(self, running=""): + pgrep = self.bin / "pgrep" + pgrep.write_text("#!/bin/sh\nprintf '%s' '" + running + "'\n") + pgrep.chmod(0o755) + env = dict(os.environ, PATH="%s:%s" % (self.bin, os.environ["PATH"])) + return subprocess.run(["bash", "-c", sweep_function(self.tmp) + "clear_stale_tmp"], env=env, + capture_output=True, text=True, timeout=30, check=True).stdout + + def exists(self, name): + return os.path.lexists(self.tmp / name.rstrip("=").split("/")[0]) + + def test_what_killed_sessions_left_goes_and_nothing_else(self): + self.assertEqual("== /tmp: cleared 8 entries earlier sessions left\n", self.sweep()) + for name in STALE: + self.assertFalse(self.exists(name), name) + for name in KEPT: + self.assertTrue((self.tmp / name).exists(), name) + + def kept_while(self, running, owned): + self.sweep(running) + for name in STALE: + self.assertEqual(name in owned, self.exists(name), name) + + def test_a_running_client_keeps_its_own(self): + self.kept_while("1234 /root/.local/share/Steam/steamrtarm64/steam -gamepadui\n", STEAM) + + def test_a_running_chromium_keeps_its_singleton(self): + self.kept_while("88 /opt/Heroic/heroic --type=renderer --enable-features=x\n", CHROMIUM) + + def test_a_running_appimage_keeps_its_stamp(self): + self.kept_while("77 /bin/bash /usr/local/bin/droiddeck-appimage-run /root/Apps/X.AppImage\n", APPIMAGE) + + def test_a_running_session_bus_keeps_its_socket(self): + self.kept_while("66 dbus-daemon --config-file=/usr/share/dbus-1/session.conf --nofork --session\n", BUS) + + def test_the_system_bus_is_not_a_session_bus(self): + self.kept_while("55 dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf --nofork\n", []) + + def test_a_running_desktop_keeps_its_decorations(self): + self.kept_while("44 kwin_wayland --xwayland\n", KDE) + + def test_a_clean_tmp_says_nothing(self): + self.sweep() + self.assertEqual("", self.sweep()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_ui_scale.py b/tools/tests/test_steam_ui_scale.py index 8d4689483..5440dcd49 100644 --- a/tools/tests/test_steam_ui_scale.py +++ b/tools/tests/test_steam_ui_scale.py @@ -14,6 +14,8 @@ DISPLAY = MODULE['DISPLAY'] seed_native_resolution = MODULE['seed_native_resolution'] initialize_native_resolution = MODULE['initialize_native_resolution'] NATIVE_RESOLUTION = MODULE['NATIVE_RESOLUTION'] +seed_no_screensaver_in_game = MODULE['seed_no_screensaver_in_game'] +initialize_screensaver = MODULE['initialize_screensaver'] class SteamUiScaleTest(unittest.TestCase): @@ -151,3 +153,60 @@ class SteamNativeResolutionTest(unittest.TestCase): initialize_native_resolution(self.root) self.assertEqual(text, self.config.read_text()) self.assertFalse(self.marker.exists()) + + +class SteamScreensaverTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.config = self.root / 'config/config.vdf' + self.config.parent.mkdir() + self.marker = self.config.with_name('droiddeck-screensaver-initialized') + + def in_game(self, text): + return parse(text).get('InstallConfigStore').get('Customization').get('Screensaver').get('WhileInGame') + + def test_fresh_client_shows_no_screensaver_over_a_game(self): + self.assertTrue(initialize_screensaver(self.root)) + self.assertEqual('0', self.in_game(self.config.read_text())) + self.assertTrue(self.marker.exists()) + + def test_it_goes_beside_the_screensaver_the_user_picked(self): + text = ('"InstallConfigStore"\n{\n\t"Customization"\n\t{\n\t\t"Screensaver"\n\t\t{\n' + '\t\t\t"CurrentScreensaver"\t\t"steam-bouncinglogo"\n\t\t}\n\t}\n}\n') + updated, changed = seed_no_screensaver_in_game(text) + self.assertTrue(changed) + screensaver = parse(updated).get('InstallConfigStore').get('Customization').get('Screensaver') + self.assertEqual('steam-bouncinglogo', screensaver.get('CurrentScreensaver')) + self.assertEqual('0', screensaver.get('WhileInGame')) + self.assertIn('\t\t\t"WhileInGame"\t\t"0"\n\t\t}\n', updated) + + def test_users_choice_is_kept(self): + text = '"InstallConfigStore" { "Customization" { "Screensaver" { "WhileInGame" "1" } } }' + self.assertEqual((text, False), seed_no_screensaver_in_game(text)) + + def test_turning_it_back_on_survives_restart(self): + initialize_screensaver(self.root) + text = '"InstallConfigStore" { "Customization" { } }' + self.config.write_text(text) + self.assertFalse(initialize_screensaver(self.root)) + self.assertEqual(text, self.config.read_text()) + + def test_every_seeded_setting_lands_in_one_valid_config(self): + initialize(self.root, 2340, 1080) + initialize_native_resolution(self.root) + initialize_screensaver(self.root) + store = parse(self.config.read_text()).get('InstallConfigStore') + self.assertIsNotNone(store.get('UI').get('display').get(DISPLAY)) + self.assertEqual('1', store.get('Gamescope').get(NATIVE_RESOLUTION)) + self.assertEqual('0', store.get('Customization').get('Screensaver').get('WhileInGame')) + + def test_invalid_config_is_never_rewritten_or_marked_initialized(self): + for text in ('"InstallConfigStore" {', '"InstallConfigStore" { "Customization" "bad" }', + '"InstallConfigStore" { "Customization" { "Screensaver" "bad" } }'): + self.config.write_text(text) + with self.assertRaises(ValueError): + initialize_screensaver(self.root) + self.assertEqual(text, self.config.read_text()) + self.assertFalse(self.marker.exists()) From 435beb7f195b8b1debee600fc786c4011a23fcf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Mart=C3=AD?= Date: Sun, 11 Oct 2026 17:00:28 -0400 Subject: [PATCH 4/6] feat: add persistent per-game compatibility profiles (#449) * feat(profiles): add persistent per-game compatibility profiles * fix(profiles): show Windows component overrides Include per-game Windows component selections in the resolved profile summary so the session overlay marks those games as custom and shows the selected components. * fix(session): detect Proton games across runtime paths * fix(profiles): select the active Gamescope game * fix(profiles): improve per-game reset feedback * fix(profiles): align session components with game overrides * fix(session): prefer selected Steam game during refresh * fix(session): resume profile following when reopening games * chore: keep local dev testing outside the repository * test(session): remove local dev package path * fix(session): preserve profiles when refresh fails * fix(session): reject empty game profile reset scope * perf(session): cache drawer inventory by refresh revision * fix(profiles): align game presets and profile reset behavior * fix(profiles): preserve originals and normalize shortcut ids * fix(profiles): keep editor scope stable after rebase * fix(tests): host Compose tests in both build variants * fix(profiles): recover interrupted component selection saves --------- Co-authored-by: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> --- app/build.gradle | 3 + .../com/droiddeck/launcher/MainActivity.kt | 3 + .../com/droiddeck/launcher/SessionActivity.kt | 205 +++++- .../droiddeck/launcher/agent/AgentGuest.kt | 57 ++ .../com/droiddeck/launcher/core/FexPreset.kt | 7 + .../launcher/core/GameEnvironment.kt | 33 + .../droiddeck/launcher/frontend/Library.kt | 2 + .../launcher/session/ComponentsManager.kt | 316 +++++++-- .../launcher/session/GameEnvironmentStore.kt | 23 + .../session/GameProfileFollowState.kt | 14 + .../session/GameProfileInventoryCache.kt | 16 + .../launcher/session/GameProfileManager.kt | 31 + .../session/GameProfileRefreshState.kt | 13 + .../session/LatestRequestCoordinator.kt | 33 + .../launcher/session/ProtonDefault.kt | 59 ++ .../launcher/session/SelectedGameProfile.kt | 70 ++ .../launcher/session/SessionPrefs.kt | 71 ++ .../droiddeck/launcher/ui/ComponentsPage.kt | 664 +++++++++++++++++- .../droiddeck/launcher/ui/FrontEndSetup.kt | 5 +- .../launcher/ui/GameEnvironmentEditor.kt | 32 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 270 ++++++- app/src/main/res/values/strings.xml | 51 +- .../launcher/agent/AgentGuestSelectionTest.kt | 88 +++ .../launcher/core/GameEnvironmentTest.kt | 20 + .../launcher/session/ComponentsProcessTest.kt | 39 + .../launcher/session/ForcedPackageTest.kt | 328 ++++++++- .../session/GameEnvironmentStoreTest.kt | 85 +++ .../session/GameProfileFollowStateTest.kt | 17 + .../session/GameProfileInventoryCacheTest.kt | 103 +++ .../session/GameProfileManagerTest.kt | 103 +++ .../session/GameProfileRefreshStateTest.kt | 48 ++ .../session/LatestRequestCoordinatorTest.kt | 68 ++ .../launcher/session/ProtonDefaultTest.kt | 44 ++ .../session/SelectedGameProfileTest.kt | 128 ++++ .../session/TextureFilteringPrefsTest.kt | 72 ++ .../launcher/ui/ComponentsPageProfileTest.kt | 59 ++ .../launcher/ui/GameEnvironmentEditorTest.kt | 63 ++ .../launcher/ui/RobolectricComposeRule.kt | 15 + docs/development/game-environment.md | 60 +- .../overlay/usr/local/bin/droiddeck-agent | 30 + .../overlay/usr/local/bin/droiddeck-game-env | 5 +- .../usr/local/bin/droiddeck-steam-compat | 12 +- .../overlay/usr/local/bin/steam-compatibility | 109 ++- tools/tests/test_droiddeck_agent.py | 16 + tools/tests/test_game_environment.py | 134 ++++ tools/tests/test_steam_compat_live.py | 27 + 46 files changed, 3501 insertions(+), 150 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GameProfileFollowState.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GameProfileInventoryCache.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GameProfileManager.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GameProfileRefreshState.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/LatestRequestCoordinator.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SelectedGameProfile.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/agent/AgentGuestSelectionTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/ComponentsProcessTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GameProfileFollowStateTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GameProfileInventoryCacheTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GameProfileManagerTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GameProfileRefreshStateTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/LatestRequestCoordinatorTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SelectedGameProfileTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/TextureFilteringPrefsTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/GameEnvironmentEditorTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt diff --git a/app/build.gradle b/app/build.gradle index 6752f032b..659397abe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -302,4 +302,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.16.1' + testImplementation 'androidx.compose.ui:ui-test-junit4' + // The Android archive does not include the host natives used by JVM archive tests. + testRuntimeOnly 'com.github.luben:zstd-jni:1.5.2-3@jar' } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 187c7f54a..3c3c6b541 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1183,6 +1183,9 @@ class MainActivity : ComponentActivity() { onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) }, // Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came. onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false }, + games = steamGames, + generalFexPreset = fexPreset, + onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id }, gpu = drivers.state(), gpuActions = com.droiddeck.launcher.ui.GpuDriversActions( onAuto = { on -> drivers.setMode(on) }, diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 97c408b8c..883492d57 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -40,6 +40,7 @@ import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.core.TextureFiltering import com.droiddeck.launcher.files.InAppFilePicker import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.agent.AgentGuest import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.gpu.ScreenEffectLooks import com.droiddeck.launcher.gpu.ScreenEffects @@ -64,6 +65,9 @@ import com.droiddeck.launcher.session.PerfHud import com.droiddeck.launcher.session.PerfMode import com.droiddeck.launcher.session.SteamRepair import com.droiddeck.launcher.session.GameEnvironmentStore +import com.droiddeck.launcher.session.GameProfileFollowState +import com.droiddeck.launcher.session.GameProfileInventoryCache +import com.droiddeck.launcher.session.GameProfileRefreshState import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionEvents import com.droiddeck.launcher.session.SessionArtifacts @@ -73,6 +77,9 @@ import com.droiddeck.launcher.session.SessionPaths import com.droiddeck.launcher.wayland.HdrSupport import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.session.ProtonDefault +import com.droiddeck.launcher.session.SelectedGameProfile +import com.droiddeck.launcher.session.WinComponents import com.droiddeck.launcher.ui.CursorOverlay import androidx.compose.ui.graphics.asImageBitmap import com.droiddeck.launcher.ui.DrawerActions @@ -86,6 +93,7 @@ import com.droiddeck.launcher.wayland.CompositorHost import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import com.droiddeck.launcher.session.ComponentsManager +import com.droiddeck.launcher.session.LatestRequestCoordinator import com.droiddeck.launcher.ui.SessionDrawerPage import com.droiddeck.launcher.ui.sessionDrawerPages import com.droiddeck.launcher.ui.step @@ -247,6 +255,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var closeAtOnce = false /** The drawer's Components tab: the Protons as last read (ComponentsManager). */ private var drawerComponents by mutableStateOf(null) + private var drawerGameProfileId by mutableStateOf(null) + private val drawerGameInventory = GameProfileInventoryCache() + private var drawerGameInventoryRevision = 0L + private var drawerGameFexPresetSaving by mutableStateOf(false) + private val drawerGameProfileFollowState = GameProfileFollowState() + private data class DrawerGameProfileRefresh( + val followsSteam: Boolean, + val requestedAppId: Long?, + val inventoryRevision: Long, + ) + private data class DrawerGameProfileResult( + val profiles: List>, + val selectedAppId: Long?, + val profile: SelectedGameProfile?, + val error: Exception? = null, + ) + private var drawerGameProfileRefresh by mutableStateOf(GameProfileRefreshState()) + private val drawerGameProfileRefreshes = LatestRequestCoordinator() private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER) private var drawerControllerActive by mutableStateOf(false) private var backActionsInverted by mutableStateOf(false) @@ -260,6 +286,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var effects by mutableStateOf(ScreenEffects.OFF) private var textureAnisotropy by mutableStateOf(0) private var textureLodBias by mutableStateOf(TextureFiltering.LOD_BIAS_OFF) + private var drawerTextureAnisotropy by mutableStateOf(null) + private var drawerTextureLodBias by mutableStateOf(null) private var lossless by mutableStateOf(Lossless.State.NONE) private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } @@ -544,7 +572,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { androidApps = androidApps, hudOn = hudOn, upscaler = upscaler, upscaleSharpness = upscaleSharpness, - effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias, + effects = effects, + textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy, + textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias, frameGen = frameGen, lossless = lossless, oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, @@ -569,8 +599,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e e.push(upscaler) }, - onTextureAnisotropy = { v -> SessionPrefs.setTextureAnisotropy(this@SessionActivity, v); textureAnisotropy = v }, - onTextureLodBias = { v -> SessionPrefs.setTextureLodBias(this@SessionActivity, v); textureLodBias = v }, + onTextureAnisotropy = { value -> + drawerGameProfileId?.let { appId -> + SessionPrefs.setGameTextureAnisotropy(this@SessionActivity, appId.toString(), value) + drawerTextureAnisotropy = value + } ?: value?.let { + SessionPrefs.setTextureAnisotropy(this@SessionActivity, it) + textureAnisotropy = it + } + }, + onTextureLodBias = { value -> + drawerGameProfileId?.let { appId -> + SessionPrefs.setGameTextureLodBias(this@SessionActivity, appId.toString(), value) + drawerTextureLodBias = value + } ?: value?.let { + SessionPrefs.setTextureLodBias(this@SessionActivity, it) + textureLodBias = it + } + }, onFrameGenPick = { mode -> FrameGen.set(this@SessionActivity, mode) readPrefs() @@ -666,7 +712,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onClose = { drawerOpen = false }, components = drawerComponents, onComponentsRefresh = { refreshDrawerComponents() }, + gameProfiles = drawerGameProfileRefresh.data?.profiles.orEmpty(), + selectedGameProfileId = drawerGameProfileId, + selectedGameProfile = drawerGameProfileRefresh.data + ?.takeIf { it.selectedAppId == drawerGameProfileId }?.profile, + gameProfilesLoaded = drawerGameProfileRefresh.loaded, + gameProfileRefreshFailed = drawerGameProfileRefresh.failed, + onSelectedGameProfile = ::selectDrawerGameProfile, + onGameProfilesPageOpened = { + drawerGameProfileFollowState.resumeFollowingSteam() + drawerGameInventoryRevision++ + }, + onSelectedGameProfileRefresh = ::refreshDrawerGameProfile, + gameFexPresetSaving = drawerGameFexPresetSaving, + onGameFexPreset = ::setDrawerGameFexPreset, onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) }, + onGameComponent = ::setDrawerGameComponent, )) if (SessionState.suspended) SessionPausedOverlay( title = pausedTitle(), @@ -763,6 +824,107 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, "drawer-components").start() } + private fun selectDrawerGameProfile(appId: Long?) { + drawerGameProfileFollowState.selectManually() + drawerGameProfileId = appId + drawerGameProfileRefresh = drawerGameProfileRefresh.retrying() + readDrawerTextureFiltering(appId) + refreshDrawerGameProfile() + } + + private fun readDrawerTextureFiltering(appId: Long?) { + val choice = appId?.let { SessionPrefs.gameTextureFiltering(this, it.toString()) } + drawerTextureAnisotropy = choice?.anisotropy + drawerTextureLodBias = choice?.lodBias + } + + private fun refreshDrawerGameProfile() { + val refresh = DrawerGameProfileRefresh( + drawerGameProfileFollowState.followsSteam, + drawerGameProfileId, + drawerGameInventoryRevision, + ) + if (!drawerGameProfileRefreshes.submit(refresh)) return + Thread({ + var request = drawerGameProfileRefreshes.takeLatest() + while (request != null) { + val current = request + val result = resolveDrawerGameProfileRefresh(current.value) + uiHandler.post { + if (!drawerGameProfileRefreshes.isLatest(current)) return@post + applyDrawerGameProfileRefresh(current.value, result) + } + request = drawerGameProfileRefreshes.takeLatest() + } + }, "drawer-game-profile").start() + } + + private fun resolveDrawerGameProfileRefresh( + refresh: DrawerGameProfileRefresh, + ): DrawerGameProfileResult { + return try { + val games = drawerGameInventory.get(refresh.inventoryRevision) { Library.launchableGames(this) } + val options = games.mapNotNull { game -> + game.profileKey.toLongOrNull()?.let { it to game.name } + }.sortedBy { it.second.lowercase() } + val installedAppIds = options.mapTo(mutableSetOf()) { it.first } + val selectedAppId = if (refresh.followsSteam) { + val activeAppId = AgentGuest.activeSteamAppId(this, installedAppIds) + val selectedSteamAppId = runCatching { AgentGuest.selectedSteamAppId(this) } + AgentGuest.selectedOrActiveInstalledSteamAppId(selectedSteamAppId, activeAppId, installedAppIds) + ?: refresh.requestedAppId + } else { + refresh.requestedAppId + } + val profile = selectedAppId?.let { resolveDrawerGameProfile(it, games) } + DrawerGameProfileResult(options, selectedAppId, profile) + } catch (error: Exception) { + DrawerGameProfileResult(emptyList(), refresh.requestedAppId, null, error) + } + } + + private fun applyDrawerGameProfileRefresh( + refresh: DrawerGameProfileRefresh, + result: DrawerGameProfileResult, + ) { + if (result.error != null) { + if (!drawerGameProfileRefresh.failed) { + Log.w(TAG, "Could not refresh the selected game profile", result.error) + } + drawerGameProfileRefresh = drawerGameProfileRefresh.failed() + return + } + drawerGameProfileRefresh = drawerGameProfileRefresh.succeeded(result) + if (refresh.followsSteam) { + if (drawerGameProfileFollowState.followsSteam) { + drawerGameProfileId = result.selectedAppId + readDrawerTextureFiltering(result.selectedAppId) + } + } + } + + private fun resolveDrawerGameProfile( + selectedAppId: Long, + games: List, + ): SelectedGameProfile? { + val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null + val snapshot = drawerComponents ?: ComponentsManager.snapshot(this) + val protons = snapshot.protons.map { it.proton } + val gameChoice = ProtonDefault.gameChoice(this, game.profileKey) + return SelectedGameProfile.resolve( + selectedAppId = selectedAppId, + games = games, + generalFexPreset = SessionPrefs.fexPreset(this), + environment = GameEnvironmentStore.read(this), + snapshot = snapshot, + generalProtonId = ProtonDefault.selectedId(this, protons), + gameProtonChoice = gameChoice, + gameProtonId = ProtonDefault.gameSelectedId(this, game.profileKey, protons), + gameComponents = ComponentsManager.gameComponents(this, game.profileKey), + gameWindowsComponents = WinComponents.picks(this, game.profileKey), + ) + } + private fun swapDrawerComponent(protonId: String, comp: String, value: String) { Thread({ val message = runCatching { @@ -784,6 +946,43 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, "drawer-components-swap").start() } + private fun setDrawerGameComponent(appId: Long, comp: String, file: String?) { + val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString() + Thread({ + val message = runCatching { + ComponentsManager.setGameComponent(this, appId.toString(), comp, file) + getString(R.string.comp_game_saved_next, name) + }.getOrElse { e -> + getString(R.string.session_profile_save_failed, e.message ?: e.javaClass.simpleName) + } + uiHandler.post { + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() + refreshDrawerComponents() + refreshDrawerGameProfile() + } + }, "drawer-game-component").start() + } + + private fun setDrawerGameFexPreset(appId: Long, preset: String?) { + if (drawerGameFexPresetSaving) return + drawerGameFexPresetSaving = true + val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString() + Thread({ + val message = try { + GameEnvironmentStore.setGameFexPreset(this, appId.toString(), preset) + getString(R.string.comp_game_saved_next, name) + } catch (error: Exception) { + Log.w(TAG, "Could not save the game FEX preset for $appId", error) + getString(R.string.session_profile_save_failed, error.message ?: error.javaClass.simpleName) + } + uiHandler.post { + drawerGameFexPresetSaving = false + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + refreshDrawerGameProfile() + } + }, "drawer-game-fex-preset").start() + } + /** The session this screen is bringing up: the live one when re-attached, else the intent's. */ private fun loadingMode(): String = if (SessionState.running) SessionState.mode diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt index fbf3f84e9..e0c0aed23 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt @@ -85,6 +85,63 @@ object AgentGuest { fun focus(context: Context): JSONObject? = if (SessionState.running) readJson(File(dir(context), "focus.json")) else null + /** The Steam library game whose tile currently has controller or keyboard focus. */ + fun selectedSteamAppId(context: Context): Long? { + val result = call(context, JSONObject().put("kind", "steam-selection"), 6_000) + if (!result.optBoolean("ok") || result.isNull("appId")) return null + return result.optLong("appId").takeIf { it in 1..4_294_967_295L } + } + + /** The installed game Gamescope is presenting, including while Steam's overlay has focus. */ + fun activeSteamAppId(context: Context, installedAppIds: Set): Long? { + val snapshot = focus(context) ?: return null + val focusedAppId = snapshot.optLong("focusedApp").takeIf { snapshot.has("focusedApp") } + val focusableAppIds = snapshot.optJSONArray("focusableApps")?.let { apps -> + buildList { + for (index in 0 until apps.length()) { + apps.optLong(index).takeIf { it > 0 }?.let(::add) + } + } + }.orEmpty() + val baselayerAppIds = snapshot.optJSONArray("baselayerAppIds")?.let { apps -> + buildList { + for (index in 0 until apps.length()) { + apps.optLong(index).takeIf { it > 0 }?.let(::add) + } + } + }.orEmpty() + return activeInstalledSteamAppId(focusedAppId, focusableAppIds, installedAppIds, baselayerAppIds) + } + + internal fun focusedInstalledSteamAppId(focusedAppId: Long?, installedAppIds: Set): Long? = + focusedAppId?.takeIf { it in installedAppIds } + + internal fun activeInstalledSteamAppId( + focusedAppId: Long?, + focusableAppIds: List, + installedAppIds: Set, + baselayerAppIds: List = emptyList(), + ): Long? = focusedInstalledSteamAppId(focusedAppId, installedAppIds) + ?: focusableAppIds.filter { it in installedAppIds }.distinct().singleOrNull() + ?: baselayerAppIds.filter { it in installedAppIds }.distinct().singleOrNull() + + internal fun preferredInstalledSteamAppId( + selectedAppId: Long?, + activeAppId: Long?, + installedAppIds: Set, + ): Long? = selectedAppId?.takeIf { it in installedAppIds } + ?: activeAppId?.takeIf { it in installedAppIds } + + internal fun selectedOrActiveInstalledSteamAppId( + selectedAppId: Result, + activeAppId: Long?, + installedAppIds: Set, + ): Long? { + preferredInstalledSteamAppId(selectedAppId.getOrNull(), activeAppId, installedAppIds)?.let { return it } + selectedAppId.exceptionOrNull()?.let { throw it } + return null + } + /** Send one request and wait for its answer. Throws [AgentException] with a stable code. */ fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject { if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context) diff --git a/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt b/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt index f0c0feb3f..832c9d039 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt @@ -30,4 +30,11 @@ object FexPreset { /** Game-launch variables in KEY=VALUE form. */ fun env(id: String): List = byId(id).env + + val environmentKeys: Set by lazy { + all.flatMap { it.env }.mapTo(linkedSetOf()) { it.substringBefore('=') } + } + + fun environment(id: String): Map = + env(id).associate { it.substringBefore('=') to it.substringAfter('=') } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt index ffc9f772b..e92da2e17 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt @@ -46,4 +46,37 @@ object GameEnvironment { fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map = defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty() + + fun hasOverride(config: Config, scope: String, name: String): Boolean = + scope.isNotEmpty() && config.entries(scope).containsKey(name) + + fun withScopeValue(config: Config, scope: String, name: String, value: String?): Config { + require(scope.isEmpty() || validScope(scope)) + val entries = config.entries(scope).toMutableMap() + if (value == null) entries.remove(name) else entries[name] = value + return config.withEntries(scope, entries) + } + + fun gameFexPreset(config: Config, scope: String): String? { + require(scope.isNotEmpty() && validScope(scope)) + val entries = config.games[scope].orEmpty() + if (FexPreset.environmentKeys.none(entries::containsKey)) return null + return FexPreset.all.firstOrNull { preset -> + val expected = FexPreset.environment(preset.id) + FexPreset.environmentKeys.all { key -> entries.containsKey(key) && entries[key] == expected[key] } + }?.id ?: CUSTOM_FEX_PRESET + } + + fun withGameFexPreset(config: Config, scope: String, preset: String?): Config { + require(scope.isNotEmpty() && validScope(scope)) + require(preset == null || FexPreset.all.any { it.id == preset }) + val entries = config.entries(scope).filterKeys { it !in FexPreset.environmentKeys }.toMutableMap() + if (preset != null) { + val values = FexPreset.environment(preset) + FexPreset.environmentKeys.forEach { key -> entries[key] = values[key] } + } + return config.withEntries(scope, entries) + } + + const val CUSTOM_FEX_PRESET = "__custom__" } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 932460779..f985b2e5b 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -31,6 +31,8 @@ object Library { ) { /** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */ val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId) + /** Key used by per-game environment and Windows-component profiles. */ + val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString() } /** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */ const val ADDED = "added" diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index 0e7045a71..3266f90e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -6,6 +6,7 @@ import android.content.Context import android.util.Log import com.droiddeck.launcher.core.Downloader import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.gpu.GpuInfo import com.droiddeck.launcher.runtime.LinuxRuntime import com.github.luben.zstd.ZstdInputStream @@ -79,7 +80,14 @@ object ComponentsManager { private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager" data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean) - data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?) + data class Component( + val detected: String, + val detail: String, + val inUse: String, + val selected: String, + val activeFile: String?, + val queued: String?, + ) data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long) data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long) data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String) @@ -129,23 +137,121 @@ object ComponentsManager { // ------------------------------------------------------------------ state - private fun loadState(context: Context): JSONObject = - runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply { + private fun pendingStateFile(context: Context) = File(dataDir(context), "state.pending.json") + + private fun loadState(context: Context): JSONObject { + recoverState(context) + return runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply { if (!has("active")) put("active", JSONObject()) if (!has("queued")) put("queued", JSONObject()) + if (!has("profiles")) put("profiles", JSONObject()) } + } + + private fun writeStateFile(file: File, text: String) { + val tmp = File(file.parentFile, file.name + ".tmp") + try { + writeSynced(tmp, text) + check(tmp.renameTo(file)) { "Could not save component selections: ${file.name}" } + } finally { + tmp.delete() + } + } + + private fun writeSynced(file: File, text: String) { + FileOutputStream(file).use { + it.write(text.toByteArray(Charsets.UTF_8)) + it.fd.sync() + } + } + + private fun recoverState(context: Context) { + val pending = pendingStateFile(context) + if (!pending.exists()) return + val transaction = JSONObject(pending.readText()) + val state = transaction.getJSONObject("state") + val expected = transaction.getString("launchSha256") + val published = File(launchDir(context), "desired.tsv") + val staged = File(launchDir(context), "desired.tsv.tmp") + // The launch-table rename is the commit point. Never read stale app selections after it. + if (!staged.exists() && published.isFile && Hashes.sha256(published) == expected) { + writeStateFile(stateFile(context), state.toString(2)) + Log.i(TAG, "recovered published component selections") + } else { + Log.i(TAG, "discarded unpublished component selections") + } + check(pending.delete()) { "Could not finish component selection recovery" } + if (staged.exists()) check(staged.delete()) { "Could not remove unpublished component selections" } + } private fun saveState(context: Context, state: JSONObject) { - val f = stateFile(context) - val tmp = File(f.parentFile, f.name + ".tmp") - tmp.writeText(state.toString(2)) - tmp.renameTo(f) - // Every change of choice reaches the launch wrappers at once. - runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) } + val prepared = prepareLaunchState(context, state) + val dir = launchDir(context) + val tmp = File(dir, "desired.tsv.tmp") + try { + writeSynced(tmp, prepared.text) + writeStateFile(pendingStateFile(context), JSONObject() + .put("state", state) + .put("launchSha256", Hashes.sha256(tmp)) + .toString()) + check(tmp.renameTo(File(dir, "desired.tsv"))) { "Could not publish component selections" } + writeStateFile(stateFile(context), state.toString(2)) + check(pendingStateFile(context).delete()) { "Could not finish saving component selections" } + } finally { + if (!pendingStateFile(context).exists()) tmp.delete() + } + File(dir, "store").listFiles()?.filter { it.isDirectory && it.name !in prepared.keep } + ?.forEach { it.deleteRecursively() } } private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) } + // Keep launch-time restore rows after the last per-game selection is removed. + private fun rememberProfiledComponent(state: JSONObject, comp: String) { + val profiled = state.optJSONArray("profiledComponents")?.let { values -> + (0 until values.length()).mapNotNull { values.optString(it).takeIf(COMPONENTS::contains) } + }.orEmpty().toMutableSet() + if (profiled.add(comp)) state.put("profiledComponents", JSONArray(profiled.sorted())) + } + + fun gameComponents(context: Context, scope: String): Map = synchronized(lock) { + val profile = loadState(context).sub("profiles").optJSONObject(scope) ?: return emptyMap() + COMPONENTS.mapNotNull { comp -> profile.optString(comp).takeIf(String::isNotEmpty)?.let { comp to it } }.toMap() + } + + fun setGameComponent(context: Context, scope: String, comp: String, file: String?) = synchronized(lock) { + require(scope.matches(Regex("[1-9][0-9]{0,9}")) && scope.toLong() <= 4294967295L) + require(comp in COMPONENTS) + val state = loadState(context) + val profiles = state.sub("profiles") + val profile = profiles.optJSONObject(scope) ?: JSONObject().also { profiles.put(scope, it) } + if (file == null) { + if (profile.optString(comp).isNotEmpty()) rememberProfiledComponent(state, comp) + profile.remove(comp) + if (profile.length() == 0) profiles.remove(scope) + } else { + val name = safeName(file) + val wcp = File(packagesDir(context), name) + require(packageInfo(context, wcp).comp == comp) + for (proton in protons(context)) ensureOriginal(context, proton, comp, state) + rememberProfiledComponent(state, comp) + profile.put(comp, name) + } + saveState(context, state) + } + + fun clearGameComponents(context: Context, scope: String) = synchronized(lock) { + require(GameEnvironment.validScope(scope)) + val state = loadState(context) + val profiles = state.sub("profiles") + profiles.optJSONObject(scope)?.let { profile -> + COMPONENTS.filter { profile.optString(it).isNotEmpty() } + .forEach { rememberProfiledComponent(state, it) } + } + profiles.remove(scope) + saveState(context, state) + } + /** Takes over the Decky plugin's packages, originals and swap records, once. */ private fun migratePlugin(context: Context) { val marker = File(dataDir(context), ".plugin-imported") @@ -220,24 +326,33 @@ object ComponentsManager { * Is anything running on this Proton right now - a game, or the client's own start-up runs, * whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines. */ - fun inUse(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") } + fun inUse(context: Context, proton: Proton): Boolean = + anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = false) } /** * Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun * (Proton's own script stays up until the game exits); the client's start-up runs do not. */ - fun gameRunning(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") } + fun gameRunning(context: Context, proton: Proton): Boolean = + anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = true) } - private fun anyProcess(context: Context, proton: Proton, match: (String, String) -> Boolean): Boolean { - val needles = (listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath)) - .map { it.trimEnd('/') + "/" } + internal fun processUsesProton(paths: List, text: String, gameOnly: Boolean): Boolean = + paths.any { path -> + val needle = path.trimEnd('/') + "/" + if (gameOnly) text.contains(needle + "proton waitforexitandrun") + else text.contains(needle + "proton ") || text.contains(needle + "files/bin") + } + + private fun anyProcess(context: Context, proton: Proton, match: (List, String) -> Boolean): Boolean { + val guestPaths = listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath) + val paths = (guestPaths + guestPaths.map { host(context, it).absolutePath } + proton.dir.absolutePath).distinct() val procs = File("/proc").listFiles() ?: return false for (p in procs) { if (!p.name.all(Char::isDigit)) continue val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue if (cmd.isEmpty()) continue val text = String(cmd).replace('\u0000', ' ') - if (needles.any { match(it, text) }) return true + if (match(paths, text)) return true } return false } @@ -261,7 +376,7 @@ object ComponentsManager { private fun compFiles(dir: File, comp: String): List { if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile } val base = File(dir, COMP_DIR.getValue(comp)) - return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList() + return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).invariantSeparatorsPath }.sorted().toList() } private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel } @@ -352,7 +467,7 @@ object ComponentsManager { tar.closeArchiveEntry() } } - tmp.renameTo(dest) + check(tmp.renameTo(dest)) { "Could not save original bundle ${dest.name}" } } // ------------------------------------------------------------------ originals @@ -380,8 +495,18 @@ object ComponentsManager { if (dest.isFile) return val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp) if (active != null && active.optString("protonVersion") == p.version) return + val published = File(launchDir(context), "desired.tsv") + if (published.isFile) { + val mayHaveOverride = published.useLines { rows -> + rows.any { row -> + val fields = row.split('\t') + fields.size == 7 && fields[0] != "*" && fields[1] == p.dir.name && + fields[2] == p.version && fields[3] == comp + } + } + check(!mayHaveOverride) { context.getString(R.string.cmgr_original_missing) } + } val rels = compFiles(p.dir, comp) - if (rels.isEmpty()) return val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version")) val profile = JSONObject() .put("type", TYPE_OF.getValue(comp)) @@ -400,7 +525,7 @@ object ComponentsManager { for (p in protons(context)) for (comp in COMPONENTS) { runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) } } - runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) } + runCatching { saveState(context, state) }.onFailure { Log.w(TAG, "launch state", it) } } // ------------------------------------------------------------------ view @@ -431,9 +556,14 @@ object ComponentsManager { else context.getString(R.string.cmgr_changed_outside) } } + val selectedLabel = when { + active == null || active.optString("protonVersion") != p.version -> + context.getString(R.string.comp_tag_original) + else -> originalLabel(context, active) ?: active.optString("label", active.optString("file")) + } val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp) Component( - detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel, + detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel, selectedLabel, active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version }, q?.let { originalLabel(context, it) ?: it.optString("label") }, ) @@ -455,64 +585,111 @@ object ComponentsManager { // ------------------------------------------------------------------ launch-time enforcement + private data class PreparedLaunchState(val text: String, val keep: Set) + /** - * Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per - * file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of - * every package or older-build original in use to copy from. Rebuilt whole after every change, so - * a restored or deleted choice simply stops being enforced. The Proton's own current-build - * originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is - * what overwrote files in the first place. + * Stages what each Proton should use for the launch wrappers. General rows use scope `*`; + * per-game rows use the game's compatdata id and replace General for that component. Deletion + * rows remove managed DLLs absent from the chosen package, so switching games cannot leave files + * from the previous game's component set behind. */ - private fun syncLaunchState(context: Context, state: JSONObject) { + private fun prepareLaunchState(context: Context, state: JSONObject): PreparedLaunchState { val dir = launchDir(context) val store = File(dir, "store").apply { mkdirs() } val all = protons(context) val lines = StringBuilder() val keep = HashSet() - val active = state.sub("active") - for (pid in active.keys()) { - val p = all.firstOrNull { it.id == pid } ?: continue - val comps = active.optJSONObject(pid) ?: continue - for (comp in comps.keys()) { - val a = comps.optJSONObject(comp) ?: continue - if (a.optString("protonVersion") != p.version) continue - val file = a.optString("file") - val (key, wcp) = if (file.startsWith("original ")) { - val v = file.removePrefix("original ") - "original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp") - } else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file) - if (!wcp.isFile) continue - val unpacked = File(store, key) - if (!File(unpacked, ".complete").isFile) { - unpacked.deleteRecursively() - readWcp(wcp) { tar -> - while (true) { - val e = tar.nextTarEntry ?: break - val rel = normalize(e.name) - if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue - val out = File(unpacked, rel) - out.parentFile?.mkdirs() - FileOutputStream(out).use { tar.copyTo(it) } - } + + fun unpack(key: String, wcp: File, comp: String): File { + check(wcp.isFile) { context.getString(R.string.cmgr_not_stored) } + val unpacked = File(store, key) + if (!File(unpacked, ".complete").isFile) { + check(packageInfo(context, wcp).comp == comp) { "Wrong component in ${wcp.name}" } + unpacked.deleteRecursively() + check(unpacked.mkdirs()) { "Could not stage component bundle ${wcp.name}" } + readWcp(wcp) { tar -> + while (true) { + val e = tar.nextTarEntry ?: break + val rel = normalize(e.name) + if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue + val out = File(unpacked, rel) + out.parentFile?.mkdirs() + FileOutputStream(out).use { tar.copyTo(it) } } - File(unpacked, ".complete").writeText("1\n") - } - keep += key - val files = a.optJSONObject("files") ?: continue - for (rel in files.keys()) { - val src = File(unpacked, rel) - if (!src.isFile) continue - val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/') - lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t') - .append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n') } + File(unpacked, ".complete").writeText("1\n") + } + keep += key + return unpacked + } + + fun managed(comp: String): Set = if (comp == "fex") FEX_FILES.toSet() else buildSet { + for (arch in PE_ARCHES) for (dll in COMP_DLLS.getValue(comp)) add("${COMP_DIR.getValue(comp)}/$arch/$dll") + } + + fun append(scope: String, p: Proton, comp: String, key: String, wcp: File) { + val unpacked = unpack(key, wcp, comp) + val files = unpacked.let { base -> + File(base, "files").walkTopDown().filter { it.isFile }.map { it.relativeTo(base).invariantSeparatorsPath } + .filter { owned(comp, it) }.associateWith { File(base, it) } + } + for (rel in managed(comp) - files.keys) { + lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp) + .append('\t').append(rel).append("\t-\t-\n") + } + for ((rel, src) in files) { + val guestSrc = "/" + src.relativeTo(root(context)).invariantSeparatorsPath + lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp) + .append('\t').append(rel).append('\t').append(Hashes.sha256(src)).append('\t').append(guestSrc).append('\n') } } - store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() } - val list = File(dir, "desired.tsv") - val tmp = File(dir, "desired.tsv.tmp") - tmp.writeText(lines.toString()) - tmp.renameTo(list) + + fun choice(p: Proton, comp: String, entry: JSONObject): Pair? { + val file = entry.optString("file") + return if (file.startsWith("original ")) { + val version = safeName(file.removePrefix("original ")) + "original-${p.id}-$version-$comp" to File(originalsDir(context), "${p.id}/$version/$comp.wcp") + } else { + safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file) + }.takeIf { it.second.isFile } + } + + val profiles = state.sub("profiles") + val profiledComponents = buildSet { + state.optJSONArray("profiledComponents")?.let { stored -> + for (index in 0 until stored.length()) { + stored.optString(index).takeIf(COMPONENTS::contains)?.let(::add) + } + } + for (scope in profiles.keys()) { + val profile = profiles.optJSONObject(scope) ?: continue + COMPONENTS.filterTo(this) { profile.optString(it).isNotEmpty() } + } + } + val active = state.sub("active") + for (p in all) for (comp in COMPONENTS) { + val entry = active.optJSONObject(p.id)?.optJSONObject(comp) + val selected = entry?.takeIf { it.optString("protonVersion") == p.version }?.let { choice(p, comp, it) } + if (selected != null) append("*", p, comp, selected.first, selected.second) + else if (comp in profiledComponents) { + ensureOriginal(context, p, comp, state) + val original = originalFile(context, p, comp) + check(original.isFile) { context.getString(R.string.cmgr_original_missing) } + append("*", p, comp, "original-${p.id}-${safeName(p.version)}-$comp", original) + } + } + for (scope in profiles.keys()) { + val profile = profiles.optJSONObject(scope) ?: continue + for (comp in COMPONENTS) { + val file = profile.optString(comp) + if (file.isEmpty()) continue + val wcp = File(packagesDir(context), file) + if (!wcp.isFile) continue + val key = safeName(file.removeSuffix(".wcp")) + for (p in all) append(scope, p, comp, key, wcp) + } + } + return PreparedLaunchState(lines.toString(), keep) } // ------------------------------------------------------------------ actions @@ -615,6 +792,11 @@ object ComponentsManager { val comps = active.optJSONObject(pid) ?: continue for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) } } + val profiles = state.sub("profiles") + for (scope in profiles.keys()) { + val profile = profiles.optJSONObject(scope) ?: continue + check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) } + } val f = File(packagesDir(context), name) check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) } f.delete() diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt index affc00dc7..fa0aa5071 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt @@ -13,6 +13,7 @@ object GameEnvironmentStore { private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json" /** The published file's extra DXVK options (droiddeck-game-env reads it; the app's own file has none). */ const val DXVK_CONFIG = "dxvkConfig" + const val DXVK_CONFIG_GAMES = "dxvkConfigGames" @Synchronized fun read(context: Context): GameEnvironment.Config { @@ -57,6 +58,13 @@ object GameEnvironmentStore { publish(context, config) } + @Synchronized + fun setGameFexPreset(context: Context, scope: String, preset: String?): GameEnvironment.Config { + val config = GameEnvironment.withGameFexPreset(read(context), scope, preset) + save(context, config) + return config + } + @Synchronized fun publish(context: Context, config: GameEnvironment.Config = read(context)) { val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared) @@ -68,6 +76,21 @@ object GameEnvironmentStore { TextureFiltering.autoLodBias(SessionState.upscaleRatio), ) if (dxvk.isNotEmpty()) json.put(DXVK_CONFIG, dxvk) + val autoBias = TextureFiltering.autoLodBias(SessionState.upscaleRatio) + val generalAnisotropy = SessionPrefs.textureAnisotropy(context) + val generalLodBias = SessionPrefs.textureLodBias(context) + val gameDxvk = JSONObject() + SessionPrefs.gameTextureFiltering(context).forEach { (scope, choice) -> + gameDxvk.put( + scope, + TextureFiltering.dxvkOptions( + choice.anisotropy ?: generalAnisotropy, + choice.lodBias ?: generalLodBias, + autoBias, + ), + ) + } + if (gameDxvk.length() > 0) json.put(DXVK_CONFIG_GAMES, gameDxvk) write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString()) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameProfileFollowState.kt b/app/src/main/java/com/droiddeck/launcher/session/GameProfileFollowState.kt new file mode 100644 index 000000000..77e9f67b1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameProfileFollowState.kt @@ -0,0 +1,14 @@ +package com.droiddeck.launcher.session + +internal class GameProfileFollowState { + var followsSteam = true + private set + + fun selectManually() { + followsSteam = false + } + + fun resumeFollowingSteam() { + followsSteam = true + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameProfileInventoryCache.kt b/app/src/main/java/com/droiddeck/launcher/session/GameProfileInventoryCache.kt new file mode 100644 index 000000000..1574c7d36 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameProfileInventoryCache.kt @@ -0,0 +1,16 @@ +package com.droiddeck.launcher.session + +internal class GameProfileInventoryCache { + private val lock = Any() + + private var revision: Long? = null + private var value: List? = null + + // Called only by the refresh worker; the UI invalidates through request revisions. + fun get(requestedRevision: Long, load: () -> List): List = synchronized(lock) { + value?.takeIf { revision == requestedRevision } ?: load().toList().also { + value = it + revision = requestedRevision + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameProfileManager.kt b/app/src/main/java/com/droiddeck/launcher/session/GameProfileManager.kt new file mode 100644 index 000000000..ed1d357b3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameProfileManager.kt @@ -0,0 +1,31 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.core.GameEnvironment + +object GameProfileManager { + @Synchronized + fun reset(context: Context, scope: String) { + require(scope.isNotEmpty() && GameEnvironment.validScope(scope)) + val failures = mutableListOf>() + fun clear(name: String, action: () -> Unit) { + runCatching(action).onFailure { failures += name to it } + } + + clear("environment") { + GameEnvironmentStore.save( + context, + GameEnvironmentStore.read(context).withEntries(scope, emptyMap()), + ) + } + clear("Proton") { ProtonDefault.requestGame(context, scope, null) } + clear("component packages") { ComponentsManager.clearGameComponents(context, scope) } + clear("texture filtering") { SessionPrefs.clearGameTextureFiltering(context, scope) } + clear("Windows components") { WinComponents.setPicks(context, scope, emptyList()) } + + if (failures.isNotEmpty()) { + val names = failures.joinToString { it.first } + throw IllegalStateException("Could not clear: $names", failures.first().second) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameProfileRefreshState.kt b/app/src/main/java/com/droiddeck/launcher/session/GameProfileRefreshState.kt new file mode 100644 index 000000000..721953abc --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameProfileRefreshState.kt @@ -0,0 +1,13 @@ +package com.droiddeck.launcher.session + +internal data class GameProfileRefreshState( + val data: T? = null, + val loaded: Boolean = false, + val failed: Boolean = false, +) { + fun succeeded(data: T) = GameProfileRefreshState(data, loaded = true) + + fun failed() = copy(failed = true) + + fun retrying() = copy(failed = false) +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/LatestRequestCoordinator.kt b/app/src/main/java/com/droiddeck/launcher/session/LatestRequestCoordinator.kt new file mode 100644 index 000000000..611c17dec --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/LatestRequestCoordinator.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.session + +internal class LatestRequestCoordinator { + data class Request(val generation: Long, val value: T) + + private val lock = Any() + private var generation = 0L + private var running = false + private var latest: Request? = null + private var pending: Request? = null + + fun submit(value: T): Boolean = synchronized(lock) { + if (latest?.value != value) generation++ + pending = Request(generation, value).also { latest = it } + if (running) { + false + } else { + running = true + true + } + } + + fun takeLatest(): Request? = synchronized(lock) { + pending.also { + pending = null + if (it == null) running = false + } + } + + fun isLatest(request: Request): Boolean = synchronized(lock) { + request.generation == latest?.generation + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt index 12a517e08..ff3214cf7 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.session import android.content.Context import android.util.Log +import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.runtime.LinuxRuntime import org.json.JSONObject import java.io.File @@ -9,10 +10,12 @@ import java.io.File object ProtonDefault { private const val TAG = "ProtonDefault" private const val DIR = "root/.local/share/droiddeck-compat" + private const val GAMES = "games.json" data class Tool(val name: String, val display: String, val dir: String, val valve: Boolean) data class Request(val seq: Long, val valve: Boolean, val dir: String) data class State(val default: String?, val applied: Long, val live: Boolean, val pending: String?, val tools: List) + data class GameChoice(val valve: Boolean, val dir: String) enum class Outcome { LIVE, NEXT_START, NOT_RUNNABLE } @@ -60,6 +63,33 @@ object ProtonDefault { return protons.firstOrNull { it.valve == pick.first && it.dir.name == pick.second }?.id } + internal fun parseGames(text: String?): Map { + val root = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return emptyMap() + if (root.optInt("version", 0) != 1) return emptyMap() + val games = root.optJSONObject("games") ?: return emptyMap() + return buildMap { + games.keys().forEach { id -> + if (!GameEnvironment.validScope(id)) return@forEach + if (games.isNull(id)) { + put(id, null) + } else { + val value = games.optJSONObject(id) ?: return@forEach + val gameDir = value.optString("dir") + val valve = value.opt("valve") + if (gameDir.isNotEmpty() && valve is Boolean) put(id, GameChoice(valve, gameDir)) + } + } + } + } + + fun gameChoice(context: Context, scope: String): GameChoice? = + parseGames(read(File(dir(context), GAMES)))[scope] + + fun gameSelectedId(context: Context, scope: String, protons: List): String? { + val pick = gameChoice(context, scope) ?: return null + return protons.firstOrNull { it.valve == pick.valve && it.dir.name == pick.dir }?.id + } + internal fun nextSeq(now: Long, state: State, previous: Request?): Long = maxOf(now, state.applied + 1, (previous?.seq ?: 0) + 1) @@ -83,4 +113,33 @@ object ProtonDefault { val tool = state.tools.firstOrNull { it.valve == proton.valve && it.dir == proton.dir.name } return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START } + + @Synchronized + fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome { + require(GameEnvironment.validScope(scope)) + val d = dir(context) + val choices = parseGames(read(File(d, GAMES))).toMutableMap() + choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) } + val games = JSONObject() + choices.forEach { (id, choice) -> + games.put(id, choice?.let { JSONObject().put("valve", it.valve).put("dir", it.dir) } ?: JSONObject.NULL) + } + val staged = File(d, "$GAMES.staged") + try { + d.mkdirs() + staged.writeText(JSONObject().put("version", 1).put("games", games).toString(1) + "\n") + if (!staged.renameTo(File(d, GAMES))) throw java.io.IOException("rename failed") + } catch (e: Exception) { + staged.delete() + Log.w(TAG, "could not write the per-game Proton request", e) + throw e + } + val state = state(context) + val tool = proton?.let { selected -> state.tools.firstOrNull { it.valve == selected.valve && it.dir == selected.dir.name } } + return if (SessionState.running && state.live && (proton == null || tool != null && state.pending != tool.name)) { + Outcome.LIVE + } else { + Outcome.NEXT_START + } + } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SelectedGameProfile.kt b/app/src/main/java/com/droiddeck/launcher/session/SelectedGameProfile.kt new file mode 100644 index 000000000..ddb21c698 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SelectedGameProfile.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.core.FexPreset +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.frontend.Library + +data class ProfileValue(val value: String, val inherited: Boolean) + +data class SelectedGameProfile( + val appId: Long, + val name: String, + val fexPreset: ProfileValue, + val proton: ProfileValue, + val protonId: String?, + val components: Map, + val componentFiles: Map, + val environmentOverrides: Int, + val windowsComponentNames: List, +) { + val hasOverrides: Boolean + get() = !fexPreset.inherited || + !proton.inherited || + components.values.any { !it.inherited } || + environmentOverrides > 0 || + windowsComponentNames.isNotEmpty() + + companion object { + fun resolve( + selectedAppId: Long, + games: List, + generalFexPreset: String, + environment: GameEnvironment.Config, + snapshot: ComponentsManager.Snapshot, + generalProtonId: String?, + gameProtonChoice: ProtonDefault.GameChoice?, + gameProtonId: String?, + gameComponents: Map, + gameWindowsComponents: List, + ): SelectedGameProfile? { + val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null + val fexOverride = GameEnvironment.gameFexPreset(environment, game.profileKey) + val selectedProtonId = if (gameProtonChoice == null) generalProtonId else gameProtonId + val selectedProton = snapshot.protons.firstOrNull { it.proton.id == selectedProtonId } + val packages = snapshot.packages.associateBy { it.file } + val components = ComponentsManager.COMPONENTS.associateWith { component -> + val override = gameComponents[component] + ProfileValue( + value = override?.let { packages[it]?.version ?: it } + ?: selectedProton?.components?.get(component)?.selected.orEmpty(), + inherited = override == null, + ) + } + val otherEnvironment = environment.games[game.profileKey].orEmpty().keys - FexPreset.environmentKeys + return SelectedGameProfile( + appId = selectedAppId, + name = game.name, + fexPreset = ProfileValue(fexOverride ?: generalFexPreset, fexOverride == null), + proton = ProfileValue( + selectedProton?.proton?.name ?: gameProtonChoice?.dir.orEmpty(), + gameProtonChoice == null, + ), + protonId = selectedProtonId, + components = components, + componentFiles = gameComponents, + environmentOverrides = otherEnvironment.size, + windowsComponentNames = gameWindowsComponents.map(WinComponentNames::of), + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 5e1387cab..1596c2fe5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -4,12 +4,15 @@ import android.content.Context import android.content.pm.ActivityInfo import androidx.annotation.StringRes import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.core.TextureFiltering import com.droiddeck.launcher.gpu.ScreenEffects import org.json.JSONObject /** The in-session switches: the HUD and how the on-screen controls decide to appear. */ object SessionPrefs { + data class GameTextureFiltering(val anisotropy: Int? = null, val lodBias: String? = null) + const val SUSPEND_AUTO = "auto" const val SUSPEND_NATIVE = "native" const val SUSPEND_MANUAL = "manual" @@ -868,6 +871,74 @@ object SessionPrefs { publishGameEnvironment(context) } + fun gameTextureFiltering(context: Context, scope: String): GameTextureFiltering { + require(GameEnvironment.validScope(scope)) + return gameTextureFiltering(context)[scope] ?: GameTextureFiltering() + } + + fun gameTextureFiltering(context: Context): Map { + val json = runCatching { + JSONObject(prefs(context).getString("gameTextureFiltering", null) ?: "{}") + }.getOrElse { JSONObject() } + return buildMap { + json.keys().forEach { scope -> + if (!GameEnvironment.validScope(scope)) return@forEach + val entry = json.optJSONObject(scope) ?: return@forEach + val anisotropy = entry.optInt("anisotropy").takeIf { + entry.has("anisotropy") && it in TextureFiltering.ANISOTROPY + } + val lodBias = entry.optString("lodBias").takeIf { + entry.has("lodBias") && it in TextureFiltering.LOD_BIAS + } + if (anisotropy != null || lodBias != null) { + put(scope, GameTextureFiltering(anisotropy, lodBias)) + } + } + } + } + + fun setGameTextureAnisotropy(context: Context, scope: String, value: Int?) { + require(value == null || value in TextureFiltering.ANISOTROPY) + updateGameTextureFiltering(context, scope) { it.copy(anisotropy = value) } + } + + fun setGameTextureLodBias(context: Context, scope: String, choice: String?) { + require(choice == null || choice in TextureFiltering.LOD_BIAS) + updateGameTextureFiltering(context, scope) { it.copy(lodBias = choice) } + } + + fun clearGameTextureFiltering(context: Context, scope: String) { + require(GameEnvironment.validScope(scope)) + val choices = gameTextureFiltering(context).toMutableMap() + if (choices.remove(scope) == null) return + saveGameTextureFiltering(context, choices) + publishGameEnvironment(context) + } + + private fun updateGameTextureFiltering( + context: Context, + scope: String, + change: (GameTextureFiltering) -> GameTextureFiltering, + ) { + require(GameEnvironment.validScope(scope)) + val choices = gameTextureFiltering(context).toMutableMap() + val next = change(choices[scope] ?: GameTextureFiltering()) + if (next.anisotropy == null && next.lodBias == null) choices.remove(scope) else choices[scope] = next + saveGameTextureFiltering(context, choices) + publishGameEnvironment(context) + } + + private fun saveGameTextureFiltering(context: Context, choices: Map) { + val json = JSONObject() + choices.forEach { (id, choice) -> + json.put(id, JSONObject().apply { + choice.anisotropy?.let { put("anisotropy", it) } + choice.lodBias?.let { put("lodBias", it) } + }) + } + prefs(context).edit().putString("gameTextureFiltering", json.toString()).apply() + } + /** Hands the change to the next game launch (GameEnvironmentStore); the running game keeps its own. */ private fun publishGameEnvironment(context: Context) { runCatching { GameEnvironmentStore.publish(context) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 7eb708117..7c5b6c9ad 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -37,6 +37,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -80,6 +81,19 @@ import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.session.ComponentsManager.CatalogItem import com.droiddeck.launcher.session.ComponentsManager.Snapshot import kotlinx.coroutines.delay +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.rememberCoroutineScope +import com.droiddeck.launcher.core.FexPreset +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.session.GameEnvironmentStore +import com.droiddeck.launcher.session.GameProfileManager +import com.droiddeck.launcher.session.ProtonDefault +import com.droiddeck.launcher.session.WinComponents +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.text.DateFormat import java.util.Date @@ -108,6 +122,13 @@ private class InstalledItem( val protonVersion: String?, ) +private data class ProfileEditorState( + val environment: GameEnvironment.Config, + val protonChoice: ProtonDefault.GameChoice?, + val protonId: String?, + val components: Map, +) + private val GOLD = Color(0xFFF2C66D) private val QUEUED = Color(0xFFFFB86B) private val ROW_SHAPE = RoundedCornerShape(10.dp) @@ -142,6 +163,9 @@ fun ComponentsPage( onRefresh: () -> Unit, onImport: () -> Unit, onBack: () -> Unit, + games: List = emptyList(), + generalFexPreset: String = "", + onGeneralFexPreset: (String) -> Unit = {}, requestInitialFocus: Boolean = true, /** The GPU drivers layer ([GPU_TAB]): what it shows and does. */ gpu: GpuDriversState = GpuDriversState(), @@ -160,6 +184,209 @@ fun ComponentsPage( val inputMode = LocalInputModeManager.current val layer = if (comp in LAYERS) comp else GPU_TAB BackHandler(onBack = onBack) + var scopeMenu by remember { mutableStateOf(false) } + var selectedGameKey by remember { mutableStateOf(null) } + var fexMenu by remember { mutableStateOf(false) } + var gameProtonMenu by remember { mutableStateOf(false) } + var gameProtonId by remember { mutableStateOf(null) } + var gameProtonChoice by remember { mutableStateOf(null) } + var gameComponents by remember { mutableStateOf>(emptyMap()) } + var gameComponentMenu by remember { mutableStateOf(null) } + var environmentEditor by remember { mutableStateOf(false) } + var winComponentsOpen by remember { mutableStateOf(null) } + var winComponentsRevision by remember { mutableStateOf(0) } + var environment by remember { mutableStateOf(null) } + var profileLoading by remember { mutableStateOf(false) } + var profileLoadGeneration by remember { mutableStateOf(0L) } + var environmentBusy by remember { mutableStateOf(false) } + var environmentError by remember { mutableStateOf(false) } + var profileNotice by remember { mutableStateOf(null) } + var confirm by remember { mutableStateOf Unit>?>(null) } + var confirmTitle by remember { mutableStateOf("") } + var confirmVerb by remember { mutableStateOf(R.string.comp_swap) } + fun ask(title: String, body: String, verb: Int, action: () -> Unit) { + confirmTitle = title + confirmVerb = verb + confirm = body to action + } + val coroutine = rememberCoroutineScope() + val selectedGame = games.firstOrNull { it.profileKey == selectedGameKey } + LaunchedEffect(Unit) { + runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.read(ctx) } } + .onSuccess { environment = it } + .onFailure { environmentError = true } + } + LaunchedEffect(games, selectedGameKey) { + if (selectedGameKey != null && games.none { it.profileKey == selectedGameKey }) selectedGameKey = null + } + LaunchedEffect(selectedGame?.profileKey, snapshot) { + profileLoadGeneration++ + val generation = profileLoadGeneration + val game = selectedGame + profileNotice = null + environmentError = false + gameProtonChoice = null + gameProtonId = null + gameComponents = emptyMap() + gameProtonMenu = false + gameComponentMenu = null + fexMenu = false + if (game == null) { + profileLoading = false + return@LaunchedEffect + } + profileLoading = true + try { + val (choice, id, components) = withContext(Dispatchers.IO) { + val choice = ProtonDefault.gameChoice(ctx, game.profileKey) + Triple( + choice, + ProtonDefault.gameSelectedId(ctx, game.profileKey, snapshot?.protons.orEmpty().map { it.proton }), + ComponentsManager.gameComponents(ctx, game.profileKey), + ) + } + if (profileLoadGeneration == generation && selectedGameKey == game.profileKey) { + gameProtonChoice = choice + gameProtonId = id + gameComponents = components + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (profileLoadGeneration == generation && selectedGameKey == game.profileKey) { + environmentError = true + profileNotice = ctx.getString( + R.string.comp_game_load_failed, + game.name, + error.message ?: error.javaClass.simpleName, + ) + } + } finally { + if (profileLoadGeneration == generation) profileLoading = false + } + } + fun setGameFexPreset(preset: String?) { + val game = selectedGame ?: return + environmentBusy = true + profileNotice = null + coroutine.launch { + runCatching { + withContext(Dispatchers.IO) { + GameEnvironmentStore.setGameFexPreset(ctx, game.profileKey, preset) + } + }.onSuccess { saved -> + environment = saved + if (selectedGameKey == game.profileKey) { + environmentError = false + profileNotice = ctx.getString(R.string.comp_game_saved_next, game.name) + } + }.onFailure { + if (selectedGameKey == game.profileKey) { + environmentError = true + profileNotice = ctx.getString(R.string.comp_game_save_failed, it.message ?: it.javaClass.simpleName) + } + } + environmentBusy = false + } + } + fun setGameProton(id: String?) { + val game = selectedGame ?: return + val proton = id?.let { wanted -> snapshot?.protons?.firstOrNull { it.proton.id == wanted }?.proton } + environmentBusy = true + profileNotice = null + coroutine.launch { + runCatching { withContext(Dispatchers.IO) { ProtonDefault.requestGame(ctx, game.profileKey, proton) } } + .onSuccess { outcome -> + if (selectedGameKey == game.profileKey) { + gameProtonChoice = proton?.let { ProtonDefault.GameChoice(it.valve, it.dir.name) } + gameProtonId = id + environmentError = false + profileNotice = ctx.getString( + when (outcome) { + ProtonDefault.Outcome.LIVE -> R.string.comp_game_proton_live + ProtonDefault.Outcome.NEXT_START -> R.string.comp_game_proton_next + ProtonDefault.Outcome.NOT_RUNNABLE -> R.string.comp_game_proton_not_runnable + }, + game.name, + ) + } + } + .onFailure { + if (selectedGameKey == game.profileKey) { + environmentError = true + profileNotice = ctx.getString(R.string.comp_game_save_failed, it.message ?: it.javaClass.simpleName) + } + } + environmentBusy = false + } + } + fun setGameComponent(comp: String, file: String?) { + val game = selectedGame ?: return + environmentBusy = true + profileNotice = null + coroutine.launch { + runCatching { withContext(Dispatchers.IO) { ComponentsManager.setGameComponent(ctx, game.profileKey, comp, file) } } + .onSuccess { + val components = ComponentsManager.gameComponents(ctx, game.profileKey) + if (selectedGameKey == game.profileKey) { + gameComponents = components + environmentError = false + profileNotice = ctx.getString(R.string.comp_game_saved_next, game.name) + } + } + .onFailure { + if (selectedGameKey == game.profileKey) { + environmentError = true + profileNotice = ctx.getString(R.string.comp_game_save_failed, it.message ?: it.javaClass.simpleName) + } + } + environmentBusy = false + } + } + fun resetGameProfile() { + val game = selectedGame ?: return + environmentBusy = true + profileNotice = null + coroutine.launch { + runCatching { + withContext(Dispatchers.IO) { + val resetFailure = runCatching { GameProfileManager.reset(ctx, game.profileKey) }.exceptionOrNull() + val config = GameEnvironmentStore.read(ctx) + val choice = ProtonDefault.gameChoice(ctx, game.profileKey) + val id = ProtonDefault.gameSelectedId( + ctx, + game.profileKey, + snapshot?.protons.orEmpty().map { it.proton }, + ) + val components = ComponentsManager.gameComponents(ctx, game.profileKey) + ProfileEditorState(config, choice, id, components) to resetFailure + } + }.onSuccess { (state, resetFailure) -> + environment = state.environment + if (selectedGameKey == game.profileKey) { + gameProtonChoice = state.protonChoice + gameProtonId = state.protonId + gameComponents = state.components + winComponentsRevision++ + environmentError = resetFailure != null + profileNotice = if (resetFailure == null) { + ctx.getString(R.string.comp_game_reset_done, game.name) + } else { + ctx.getString( + R.string.comp_game_reset_failed, + resetFailure.message ?: resetFailure.javaClass.simpleName, + ) + } + } + }.onFailure { + if (selectedGameKey == game.profileKey) { + environmentError = true + profileNotice = ctx.getString(R.string.comp_game_reset_failed, it.message ?: it.javaClass.simpleName) + } + } + environmentBusy = false + } + } val views = snapshot?.protons ?: emptyList() val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull() @@ -247,7 +474,7 @@ fun ComponentsPage( val layerFocus = remember { LAYERS.associateWith { FocusRequester() } } // Arrived (from the rail, a hop, a game's card): a pad starts on the chosen layer. LaunchedEffect(requestInitialFocus) { - if (!requestInitialFocus || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect + if (!requestInitialFocus || selectedGame != null || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect delay(Motion.ms(200).toLong()) focusSoon { layerFocus.getValue(layer) } } @@ -259,7 +486,7 @@ fun ComponentsPage( LaunchedEffect(layer) { if (seenLayer[0] == layer) return@LaunchedEffect seenLayer[0] = layer - if (!pageFocused || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect + if (selectedGame != null || !pageFocused || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect if (narrowOpen != null) runCatching { firstRow.requestFocus() } else focusSoon { layerFocus.getValue(layer) } } @@ -534,6 +761,21 @@ fun ComponentsPage( Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } } } Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } } + if (l == "fex") { + SettingsGroup(stringResource(R.string.fex_preset_title)) { + SettingsRow(stringResource(R.string.fex_preset_title), null) { + FexPresetChip( + preset = generalFexPreset, + inherited = false, + open = fexMenu, + enabled = true, + onToggle = { fexMenu = !fexMenu }, + onDismiss = { fexMenu = false }, + onPick = { it?.let(onGeneralFexPreset) }, + ) + } + } + } if (l == PROTON_TAB) { Spacer(Modifier.height(10.dp)) if (!protons.runtimeReady) EmptyLine(stringResource(R.string.content_needs_runtime)) @@ -550,7 +792,10 @@ fun ComponentsPage( Box(Modifier.fillMaxSize().onGloballyPositioned { pageAt = it.positionInRoot() }.onFocusChanged { pageFocused = it.hasFocus }) { Column( Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp) - .bumpers(onPrevious = { pick(layerStep(layer, -1)) }, onNext = { pick(layerStep(layer, 1)) }), + .then(if (selectedGame == null) Modifier.bumpers( + onPrevious = { pick(layerStep(layer, -1)) }, + onNext = { pick(layerStep(layer, 1)) }, + ) else Modifier), ) { // ---- header: the title, the device, when the lists were checked and the one refresh ---- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { @@ -569,29 +814,109 @@ fun ComponentsPage( } if (narrow) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 6.dp)) { DeviceChip(gpu) } Spacer(Modifier.height(14.dp)) - Row(Modifier.weight(1f).fillMaxWidth()) { - // ---- the stack ---- - Box( - (if (narrow) Modifier.fillMaxWidth() else Modifier.width(250.dp)).fillMaxHeight() - .onGloballyPositioned { stackAt = it.positionInRoot() }, - ) { - val tint = rememberUpdatedState(pal.signal) - val local = cardsAt[layer]?.translate(-stackAt) - ChipGlide(LAYERS.indexOf(layer), local, tint, GlideAxis.Vertical, corner = 12.dp) - Stack( - layer, view, gpu, focus = layerFocus, landed = landed, rings = rings, - onPlaced = { l, r -> cardsAt[l] = r }, - onFocus = { l -> pick(l) }, - onOpen = { l -> - pick(l) - if (narrow) narrowOpen = l - else runCatching { firstRow.requestFocus() } + if (games.isNotEmpty()) { + SettingsGroup(stringResource(R.string.comp_profile_configure)) { + SettingsRow( + stringResource(R.string.comp_profile), + if (selectedGame == null) { + stringResource(R.string.comp_scope_general_detail) + } else { + stringResource(R.string.comp_profile_game_detail, selectedGame.name) }, - ) + ) { + Box { + ValueChip( + selectedGame?.name ?: stringResource(R.string.comp_scope_general), + scopeMenu, + modifier = Modifier.widthIn(max = 320.dp).heightIn(min = 44.dp), + ) { scopeMenu = !scopeMenu } + AnchoredMenu( + scopeMenu, + onDismiss = { scopeMenu = false }, + title = stringResource(R.string.comp_profile_configure), + ) { first -> + MenuItem( + stringResource(R.string.comp_scope_general), + checked = selectedGame == null, + detail = stringResource(R.string.comp_scope_general_detail), + focusRequester = first, + ) { + selectedGameKey = null + scopeMenu = false + } + games.sortedBy { it.name.lowercase() }.forEach { game -> + MenuItem(game.name, checked = selectedGameKey == game.profileKey) { + selectedGameKey = game.profileKey + scopeMenu = false + } + } + } + } + } } - if (!narrow) { - Spacer(Modifier.width(16.dp)) - Box(Modifier.weight(1f).fillMaxHeight()) { detail(layer, false) } + } + + if (selectedGame != null) { + GameComponentOverrides( + game = selectedGame, + config = environment, + generalFexPreset = generalFexPreset, + generalProton = view?.proton, + protons = views.map { it.proton }, + protonId = gameProtonId, + protonChoice = gameProtonChoice, + packages = snapshot?.packages.orEmpty(), + componentChoices = gameComponents, + componentMenu = gameComponentMenu, + protonMenu = gameProtonMenu, + fexMenu = fexMenu, + busy = environmentBusy || profileLoading, + error = environmentError, + notice = profileNotice, + winComponentsRevision = winComponentsRevision, + onFexMenu = { fexMenu = it }, + onFexPreset = ::setGameFexPreset, + onProtonMenu = { gameProtonMenu = it }, + onProton = ::setGameProton, + onComponentMenu = { gameComponentMenu = it }, + onComponent = ::setGameComponent, + onEnvironment = { environmentEditor = true }, + onWinComponents = { winComponentsOpen = false }, + onReset = { + ask( + ctx.getString(R.string.comp_game_reset_title, selectedGame.name), + ctx.getString(R.string.comp_game_reset_body), + R.string.comp_game_reset, + ::resetGameProfile, + ) + }, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } else { + Row(Modifier.weight(1f).fillMaxWidth()) { + // ---- the stack ---- + Box( + (if (narrow) Modifier.fillMaxWidth() else Modifier.width(250.dp)).fillMaxHeight() + .onGloballyPositioned { stackAt = it.positionInRoot() }, + ) { + val tint = rememberUpdatedState(pal.signal) + val local = cardsAt[layer]?.translate(-stackAt) + ChipGlide(LAYERS.indexOf(layer), local, tint, GlideAxis.Vertical, corner = 12.dp) + Stack( + layer, view, gpu, focus = layerFocus, landed = landed, rings = rings, + onPlaced = { l, r -> cardsAt[l] = r }, + onFocus = { l -> pick(l) }, + onOpen = { l -> + pick(l) + if (narrow) narrowOpen = l + else runCatching { firstRow.requestFocus() } + }, + ) + } + if (!narrow) { + Spacer(Modifier.width(16.dp)) + Box(Modifier.weight(1f).fillMaxHeight()) { detail(layer, false) } + } } } } @@ -636,6 +961,50 @@ fun ComponentsPage( if (from == null) body() else PageFlood(from, driverLeaving) { body() } } } + confirm?.let { (body, action) -> + AlertDialog( + onDismissRequest = { confirm = null }, + title = { Text(confirmTitle) }, + text = { Text(body, fontSize = 14.sp) }, + // Opens on Cancel, so a stray A press on a controller never swaps or deletes anything. + confirmButton = { + val del = confirmVerb == R.string.common_delete || confirmVerb == R.string.comp_game_reset + FocusText(stringResource(confirmVerb), if (del) colors.error else pal.signal) { confirm = null; action() } + }, + dismissButton = { + val cancelFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } } + FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null } + }, + ) + } + if (environmentEditor && selectedGame != null) { + GameEnvironmentEditor( + byPad = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode == androidx.compose.ui.input.InputMode.Keyboard, + initialScope = selectedGame.profileKey, + ) { + environmentEditor = false + coroutine.launch { + runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.read(ctx) } } + .onSuccess { environment = it; environmentError = false } + .onFailure { environmentError = true } + } + } + } + val winOpen = winComponentsOpen + if (winOpen != null && selectedGame != null) { + WinComponentsDialog( + selectedGame.profileKey, + selectedGame.name, + selectedGame.gameFiles, + winOpen, + compat = selectedGame.protonPrefix, + steamAppId = selectedGame.appId.takeIf { selectedGame.library != Library.ADDED }, + ) { + winComponentsOpen = null + winComponentsRevision++ + } + } } /** The Installed rows of one component layer for the Proton the page shows. */ @@ -798,6 +1167,253 @@ private fun EmptyLine(text: String) { Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) } +@Composable +private fun GameComponentOverrides( + game: Library.SteamGame, + config: GameEnvironment.Config?, + generalFexPreset: String, + generalProton: ComponentsManager.Proton?, + protons: List, + protonId: String?, + protonChoice: ProtonDefault.GameChoice?, + packages: List, + componentChoices: Map, + componentMenu: String?, + protonMenu: Boolean, + fexMenu: Boolean, + busy: Boolean, + error: Boolean, + notice: String?, + winComponentsRevision: Int, + onFexMenu: (Boolean) -> Unit, + onFexPreset: (String?) -> Unit, + onProtonMenu: (Boolean) -> Unit, + onProton: (String?) -> Unit, + onComponentMenu: (String?) -> Unit, + onComponent: (String, String?) -> Unit, + onEnvironment: () -> Unit, + onWinComponents: () -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val override = config?.let { GameEnvironment.gameFexPreset(it, game.profileKey) } + val proton = protons.firstOrNull { it.id == protonId } + val effectiveProton = if (protonChoice == null) generalProton else proton + val picks = remember(game.profileKey, winComponentsRevision) { WinComponents.picks(context, game.profileKey) } + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = modifier.verticalScroll(rememberScrollState()).padding(top = 14.dp, bottom = 16.dp), + ) { + Text( + stringResource(R.string.comp_game_hint), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SettingsGroup(stringResource(R.string.fex_preset_title)) { + SettingsRow( + stringResource(R.string.comp_game_fex), + stringResource(if (override == null) R.string.comp_game_fex_inherited else R.string.comp_game_fex_override), + ) { + FexPresetChip( + preset = override ?: generalFexPreset, + inherited = override == null, + open = fexMenu, + enabled = config != null && !busy, + allowInheritance = true, + onToggle = { onFexMenu(!fexMenu) }, + onDismiss = { onFexMenu(false) }, + onPick = onFexPreset, + ) + } + GamePackageRow( + comp = "fex", + packages = packages, + selectedFile = componentChoices["fex"], + open = componentMenu == "fex", + busy = busy, + onMenu = onComponentMenu, + onPick = onComponent, + ) + } + SettingsGroup(stringResource(R.string.comp_game_compatibility)) { + SettingsRow( + stringResource(R.string.comp_proton), + stringResource(if (protonChoice == null) R.string.comp_game_proton_inherited else R.string.comp_game_proton_override), + ) { + Box { + ValueChip( + proton?.name ?: protonChoice?.dir ?: stringResource(R.string.comp_game_use_general), + protonMenu, + enabled = protons.isNotEmpty() && !busy, + modifier = Modifier.widthIn(max = 280.dp), + ) { onProtonMenu(!protonMenu) } + AnchoredMenu( + protonMenu, + onDismiss = { onProtonMenu(false) }, + title = stringResource(R.string.comp_proton), + ) { first -> + MenuItem( + stringResource(R.string.comp_game_use_general), + checked = protonChoice == null, + focusRequester = first, + ) { onProton(null); onProtonMenu(false) } + protons.forEach { option -> + MenuItem(option.name, checked = proton?.id == option.id) { + onProton(option.id) + onProtonMenu(false) + } + } + } + } + } + GamePackageRow( + comp = "dxvk", + packages = packages, + selectedFile = componentChoices["dxvk"], + open = componentMenu == "dxvk", + busy = busy, + proton = effectiveProton, + onMenu = onComponentMenu, + onPick = onComponent, + onRuntimeOptions = onEnvironment, + ) + GamePackageRow( + comp = "vkd3d", + packages = packages, + selectedFile = componentChoices["vkd3d"], + open = componentMenu == "vkd3d", + busy = busy, + proton = effectiveProton, + onMenu = onComponentMenu, + onPick = onComponent, + onRuntimeOptions = onEnvironment, + ) + SettingsRow( + stringResource(R.string.game_env_title), + stringResource(R.string.comp_game_environment_other_hint), + ) { + SecondaryButton(stringResource(R.string.game_env_edit), enabled = !busy, onClick = onEnvironment) + } + SettingsRow( + stringResource(R.string.wincomp_title), + if (picks.isEmpty()) stringResource(R.string.wincomp_card_none) + else picks.joinToString(", ") { com.droiddeck.launcher.session.WinComponentNames.of(it) }, + ) { + SecondaryButton(stringResource(R.string.game_env_edit), onClick = onWinComponents) + } + } + notice?.let { + Text( + it, + fontSize = 13.sp, + color = if (error) MaterialTheme.colorScheme.error else LocalPalette.current.good, + ) + } + if (error && notice == null) Text(stringResource(R.string.game_env_error), fontSize = 13.sp, color = MaterialTheme.colorScheme.error) + if (!busy) { + FocusText( + stringResource(R.string.comp_game_reset), + MaterialTheme.colorScheme.error, + onClick = onReset, + ) + } + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + } +} + +@Composable +private fun GamePackageRow( + comp: String, + packages: List, + selectedFile: String?, + open: Boolean, + busy: Boolean, + proton: ComponentsManager.Proton? = null, + onMenu: (String?) -> Unit, + onPick: (String, String?) -> Unit, + onRuntimeOptions: (() -> Unit)? = null, +) { + val choices = packages.filter { it.comp == comp } + val selected = choices.firstOrNull { it.file == selectedFile } + val title = ComponentsManager.LABEL[comp] ?: comp + SettingsRow( + title, + if (selected == null) { + if (proton == null) stringResource(R.string.comp_game_component_general) + else stringResource(R.string.comp_game_component_runtime, proton.name) + } else stringResource(R.string.comp_game_component_override, selected.version), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Box { + ValueChip( + selected?.version ?: stringResource(R.string.comp_game_use_general), + open, + enabled = !busy, + modifier = Modifier.widthIn(max = 240.dp), + ) { onMenu(if (open) null else comp) } + AnchoredMenu(open, onDismiss = { onMenu(null) }, title = title) { first -> + MenuItem( + stringResource(R.string.comp_game_use_general), + checked = selected == null, + focusRequester = first, + ) { onPick(comp, null); onMenu(null) } + choices.forEach { option -> + MenuItem(option.version, checked = option.file == selectedFile, detail = option.description) { + onPick(comp, option.file) + onMenu(null) + } + } + } + } + if (onRuntimeOptions != null) { + SecondaryButton(stringResource(R.string.comp_game_runtime_options), enabled = !busy, onClick = onRuntimeOptions) + } + } + } +} + +@Composable +internal fun FexPresetChip( + preset: String, + inherited: Boolean, + open: Boolean, + enabled: Boolean, + onToggle: () -> Unit, + onDismiss: () -> Unit, + onPick: (String?) -> Unit, + allowInheritance: Boolean = false, + modifier: Modifier = Modifier, +) { + val chosen = FexPreset.all.firstOrNull { it.id == preset } + val label = when { + preset == GameEnvironment.CUSTOM_FEX_PRESET -> stringResource(R.string.comp_game_fex_custom) + chosen == null -> stringResource(FexPreset.byId("").label) + inherited -> stringResource(R.string.comp_game_use_general) + else -> stringResource(chosen.label) + } + Box { + ValueChip(label, open, enabled = enabled, modifier = modifier.widthIn(max = 260.dp), onClick = onToggle) + AnchoredMenu(open && enabled, onDismiss = onDismiss, title = stringResource(R.string.fex_preset_title)) { first -> + if (allowInheritance) { + MenuItem( + stringResource(R.string.comp_game_use_general), + checked = inherited, + focusRequester = first, + ) { onPick(null); onDismiss() } + } + FexPreset.all.forEachIndexed { index, option -> + MenuItem( + stringResource(option.label), + checked = !inherited && option.id == preset, + detail = stringResource(option.detail), + focusRequester = if (index == 0 && !allowInheritance) first else null, + ) { onPick(option.id); onDismiss() } + } + } + } +} + @Composable internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index dfab93aa9..fd6cfec3f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -478,10 +478,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current // The game's prefix is compatdata/, as droiddeck-game-env reads it: a Steam title's appid, an // added game's shortcut appid (unsigned). Recommended scans the game's own folder either way. - val wincompKey = game?.let { - if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId) - else it.appId.takeIf { id -> id > 0 }?.toString() - } + val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey var wincompOpen by remember(wincompKey) { mutableStateOf(null) } val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() } val cards = buildList<@Composable (Modifier) -> Unit> { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt index 3ca96d6c0..51c8fdf5a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -52,7 +52,11 @@ import kotlinx.coroutines.withContext // dialog, with each variable's actions in a menu off its value as everywhere else. @Composable -fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) { +fun GameEnvironmentRow( + modifier: Modifier = Modifier, + showHint: Boolean = true, + initialScope: String = "", +) { var open by remember { mutableStateOf(false) } // Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and // would otherwise give the pad nothing to move from. @@ -64,7 +68,7 @@ fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) open = true } } - if (open) GameEnvironmentEditor(byPad) { open = false } + if (open) GameEnvironmentEditor(byPad, initialScope) { open = false } } /** With a pad driving, focus [target] once the dialog is laid out, and keep the dialog in pad mode. */ @@ -81,19 +85,19 @@ internal fun PadFocus(byPad: Boolean, target: FocusRequester) { } @Composable -private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) { +internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", onClose: () -> Unit) { val context = LocalContext.current val coroutine = rememberCoroutineScope() val shown = rememberShown(onClose) val close = { shown.targetState = false } var config by remember { mutableStateOf(null) } var games by remember { mutableStateOf(emptyList>()) } - var scope by remember { mutableStateOf("") } + var scope by remember { mutableStateOf(initialScope) } var otherId by remember { mutableStateOf(null) } var menu by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(false) } - var editing by remember { mutableStateOf?>(null) } + var editing by remember { mutableStateOf(null) } val preset = SessionPrefs.fexPreset(context) val forceSsbs = SessionPrefs.forceSsbs(context) val firstFocus = remember { FocusRequester() } @@ -168,7 +172,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) { VariableRow( name, value, inherited = !own.containsKey(name), open = menu == "var:$name", enabled = !busy, onToggle = { menu = if (menu == "var:$name") null else "var:$name" }, onDismiss = { if (menu == "var:$name") menu = null }, - onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = name to (value ?: "") }, + onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, name, value ?: "") }, // A value set here or inherited can be switched off for this profile; one set here can go back to inheriting. onUnset = if (value != null) ({ menu = null; save(current.withEntries(scope, own + (name to null))) }) else null, onRestore = if (own.containsKey(name)) ({ menu = null; save(current.withEntries(scope, own - name)) }) else null, @@ -185,9 +189,9 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) { SecondaryButton(stringResource(R.string.game_env_add), enabled = !busy) { menu = if (menu == "add") null else "add" } AnchoredMenu(menu == "add", onDismiss = { if (menu == "add") menu = null }, title = stringResource(R.string.game_env_add)) { first -> for ((i, option) in GameEnvironmentOptions.all.withIndex()) MenuItem(option.name, checked = false, focusRequester = if (i == 0) first else null) { - menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = option.name to option.value + menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, option.name, option.value) } - MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = "" to "" } + MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, "", "") } } } if (current != null && current.entries(scope).isNotEmpty()) FocusText(stringResource(R.string.game_env_reset), LocalPalette.current.signal) { @@ -197,19 +201,21 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) { PrimaryButton(stringResource(R.string.game_env_done), enabled = !busy, onClick = close) } } - editing?.let { (name, value) -> - VariableDialog(name, value, profileName, editByPad, onDismiss = { editing = null }) { key, content -> + editing?.let { edit -> + VariableDialog(edit.name, edit.value, edit.profileName, editByPad, onDismiss = { editing = null }) { key, content -> config?.let { - val entries = it.entries(scope).toMutableMap() - if (name.isNotEmpty() && name != key) entries[name] = null + val entries = it.entries(edit.scope).toMutableMap() + if (edit.name.isNotEmpty() && edit.name != key) entries[edit.name] = null entries[key] = content - save(it.withEntries(scope, entries)) + save(it.withEntries(edit.scope, entries)) } editing = null } } } +private data class EnvironmentVariableEdit(val scope: String, val profileName: String, val name: String, val value: String) + /** The panel the profile and the variables sit in, as a settings group does. */ @Composable private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) = diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 893bc3cd9..678e6cbe1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import kotlin.math.roundToInt @@ -88,6 +89,9 @@ import com.droiddeck.launcher.HomeApp import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.session.ProfileValue +import com.droiddeck.launcher.session.SelectedGameProfile import androidx.compose.animation.core.animateFloatAsState import androidx.compose.ui.draw.rotate import androidx.compose.ui.semantics.stateDescription @@ -205,8 +209,8 @@ class DrawerActions( /** The compositor's post chain (gpu/ScreenEffects), changed live. */ val effects: ScreenEffects = ScreenEffects.OFF, /** Texture filtering for DirectX 9-11 games (core/TextureFiltering); lands on their next launch. */ - val textureAnisotropy: Int = 0, - val textureLodBias: String = TextureFiltering.LOD_BIAS_OFF, + val textureAnisotropy: Int? = 0, + val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF, val secondScreenMode: SecondScreenMode, val secondScreenDisplays: List, val selectedSecondScreenDisplay: Int, @@ -235,8 +239,8 @@ class DrawerActions( val onUpscaler: (Int) -> Unit = {}, val onUpscaleSharpness: (Int) -> Unit = {}, val onEffects: (ScreenEffects) -> Unit = {}, - val onTextureAnisotropy: (Int) -> Unit = {}, - val onTextureLodBias: (String) -> Unit = {}, + val onTextureAnisotropy: (Int?) -> Unit = {}, + val onTextureLodBias: (String?) -> Unit = {}, val onSecondScreenMode: (SecondScreenMode) -> Unit, val onSecondScreenDisplay: (Int) -> Unit, val onLaunchAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit, @@ -257,8 +261,22 @@ class DrawerActions( val components: ComponentsManager.Snapshot? = null, /** Re-reads the Protons (and runs swaps that waited for a game to close). */ val onComponentsRefresh: () -> Unit = {}, + /** Installed games available for profile inspection in the session drawer. */ + val gameProfiles: List> = emptyList(), + /** The selected game profile, or null for the Default profile. */ + val selectedGameProfileId: Long? = null, + val selectedGameProfile: SelectedGameProfile? = null, + val gameProfilesLoaded: Boolean = false, + val gameProfileRefreshFailed: Boolean = false, + val onSelectedGameProfile: (Long?) -> Unit = {}, + val onGameProfilesPageOpened: () -> Unit = {}, + val onSelectedGameProfileRefresh: () -> Unit = {}, + val gameFexPresetSaving: Boolean = false, + val onGameFexPreset: (Long, String?) -> Unit = { _, _ -> }, /** Swaps [value] ("orig:" or a stored package file) into a Proton's component. */ val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> }, + /** Saves a stored package for a game, or null to inherit that component from the Default profile. */ + val onGameComponent: (appId: Long, comp: String, file: String?) -> Unit = { _, _, _ -> }, ) @OptIn(ExperimentalComposeUiApi::class) @@ -286,7 +304,19 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } BackHandler(enabled = open && confirmStop) { confirmStop = false } LaunchedEffect(page) { host.open = null; appToChooseDisplay = null } - LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() } + LaunchedEffect(a.selectedGameProfileId) { + if (host.open == "fex") host.open = null + } + LaunchedEffect(open, page) { + if (open && page == SessionDrawerPage.GAMES) { + a.onGameProfilesPageOpened() + a.onComponentsRefresh() + while (true) { + a.onSelectedGameProfileRefresh() + delay(1_000) + } + } + } LaunchedEffect(open, controllerActive) { if (open && !controllerActive) focusManager.clearFocus(force = true) } @@ -479,14 +509,17 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont SecondScreenGroup(host, a) { key -> focus.track(page, key) } } SessionDrawerPage.GAMES -> { + if (a.steam) SelectedGameProfileGroup(host, a) { key -> focus.track(page, key) } Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp)) TextureFilteringGroup(host, a) { key -> focus.track(page, key) } if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) { - ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null, - FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset, - chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset) - GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false) + DrawerFexPresetRow(host, a, focus.track(page, "fex")) + GameEnvironmentRow( + modifier = focus.track(page, "game-env"), + showHint = false, + initialScope = a.selectedGameProfileId?.toString().orEmpty(), + ) } ComponentsDrawerPage(host, a) { key -> focus.track(page, key) } } @@ -874,20 +907,187 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) } } +@Composable +private fun DrawerFexPresetRow(host: MenuHost, a: DrawerActions, modifier: Modifier) { + val appId = a.selectedGameProfileId + val profile = a.selectedGameProfile?.takeIf { it.appId == appId } + val inherited = profile?.fexPreset?.inherited == true + val hint = if (profile == null) null else stringResource( + if (inherited) R.string.comp_game_fex_inherited else R.string.comp_game_fex_override, + ) + SettingsRow(stringResource(R.string.fex_preset_title), hint, highlighted = host.open == "fex") { + if (appId != null && profile == null) { + ValueChip("-", false, enabled = false, modifier = modifier) {} + } else { + FexPresetChip( + preset = profile?.fexPreset?.value ?: a.fexPreset, + inherited = inherited, + open = host.open == "fex", + enabled = !a.gameFexPresetSaving, + onToggle = { host.open = if (host.open == "fex") null else "fex" }, + onDismiss = { if (host.open == "fex") host.open = null }, + onPick = { preset -> + if (appId == null) { + preset?.let(a.onFexPreset) + } else { + a.onGameFexPreset(appId, preset) + } + }, + allowInheritance = appId != null, + modifier = modifier, + ) + } + } +} + /** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */ @Composable private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { + val inherited = a.selectedGameProfileId != null + val anisotropyChoices: List> = + (if (inherited) listOf(null to stringResource(R.string.comp_game_use_general)) else emptyList()) + + SessionPrefs.textureAnisotropyChoices(LocalContext.current).map { (value, label) -> value as Int? to label } + val lodBiasChoices: List> = + (if (inherited) listOf(null to stringResource(R.string.comp_game_use_general)) else emptyList()) + + SessionPrefs.textureLodBiasChoices(LocalContext.current).map { (value, label) -> value as String? to label } SettingsGroup(stringResource(R.string.drawer_texture)) { ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null, - SessionPrefs.textureAnisotropyChoices(LocalContext.current), a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy) + anisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy) ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null, - SessionPrefs.textureLodBiasChoices(LocalContext.current), a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note), + lodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note), chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias) } } private fun signed(v: Int) = if (v > 0) "+$v" else "$v" +@Composable +private fun SelectedGameProfileGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { + val colors = MaterialTheme.colorScheme + val options: List> = listOf(null to stringResource(R.string.comp_scope_general)) + + a.gameProfiles.map { (id, name) -> Pair(id, name) } + val profile = a.selectedGameProfile + SettingsGroup(stringResource(R.string.drawer_selected_profile)) { + DrawerStackedChoice( + host = host, + key = "game-profile", + label = stringResource(R.string.comp_profile), + hint = if (a.selectedGameProfileId == null) stringResource(R.string.comp_scope_general_detail) + else stringResource(R.string.drawer_profile_inspect_hint), + options = options, + selected = a.selectedGameProfileId, + chipModifier = track("game-profile"), + onPick = a.onSelectedGameProfile, + ) + if (a.gameProfileRefreshFailed) { + Text( + stringResource( + if (a.gameProfilesLoaded) { + R.string.drawer_profile_refresh_failed_stale + } else { + R.string.drawer_profile_refresh_failed_initial + }, + ), + fontSize = 13.sp, + color = colors.error, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + } + if (a.selectedGameProfileId == null) return@SettingsGroup + if (profile == null) { + if (!a.gameProfileRefreshFailed) Text( + stringResource(R.string.drawer_selected_profile_none), + fontSize = 13.sp, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(14.dp), + ) + return@SettingsGroup + } + val hasOverrides = profile.hasOverrides || + a.textureAnisotropy != null || + a.textureLodBias != null + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp)) { + Text( + stringResource( + if (hasOverrides) R.string.drawer_profile_custom + else R.string.drawer_profile_default, + ), + fontSize = 12.sp, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(bottom = if (hasOverrides) 5.dp else 0.dp), + ) + if (!profile.fexPreset.inherited) { + ProfileSummaryRow(stringResource(R.string.comp_game_fex), fexProfileLabel(profile.fexPreset)) + } + if (!profile.proton.inherited) { + ProfileSummaryRow(stringResource(R.string.comp_proton), profileValueLabel(profile.proton)) + } + for (component in ComponentsManager.COMPONENTS) { + val value = profile.components.getValue(component) + if (!value.inherited) { + ProfileSummaryRow(ComponentsManager.LABEL.getValue(component), profileValueLabel(value)) + } + } + if (profile.environmentOverrides > 0) { + ProfileSummaryRow( + stringResource(R.string.game_env_title), + pluralStringResource( + R.plurals.drawer_profile_environment_overrides, + profile.environmentOverrides, + profile.environmentOverrides, + ), + ) + } + if (profile.windowsComponentNames.isNotEmpty()) { + ProfileSummaryRow( + stringResource(R.string.wincomp_title), + profile.windowsComponentNames.joinToString(", "), + ) + } + a.textureAnisotropy?.let { value -> + ProfileSummaryRow( + stringResource(R.string.drawer_anisotropy), + SessionPrefs.textureAnisotropyChoices(LocalContext.current) + .firstOrNull { it.first == value }?.second.orEmpty(), + ) + } + a.textureLodBias?.let { value -> + ProfileSummaryRow( + stringResource(R.string.drawer_texture_sharpness), + SessionPrefs.textureLodBiasChoices(LocalContext.current) + .firstOrNull { it.first == value }?.second.orEmpty(), + ) + } + } + } +} + +@Composable +private fun fexProfileLabel(value: ProfileValue): String { + return when (value.value) { + GameEnvironment.CUSTOM_FEX_PRESET -> stringResource(R.string.comp_game_fex_custom) + else -> stringResource(FexPreset.byId(value.value).label) + } +} + +@Composable +private fun profileValueLabel(value: ProfileValue) = + value.value.ifEmpty { stringResource(R.string.comp_no_proton_short) } + +@Composable +private fun ProfileSummaryRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth().padding(top = 3.dp)) { + Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(96.dp)) + Text( + value, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + /** * The Games tab's components: quick swaps between what is already installed, per Proton. A swap * into the Proton a running game uses waits until that game closes; downloading, importing and @@ -903,6 +1103,54 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin } return } + val gameProfile = a.selectedGameProfileId?.let { appId -> + a.selectedGameProfile?.takeIf { it.appId == appId } + } + if (a.selectedGameProfileId != null) { + if (gameProfile == null) { + SettingsGroup(stringResource(R.string.drawer_page_components)) { + Text( + stringResource(R.string.comp_reading), + fontSize = 13.sp, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(14.dp), + ) + } + return + } + SettingsGroup(stringResource(R.string.drawer_page_components)) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp)) { + ProfileSummaryRow(stringResource(R.string.comp_proton), gameProfile.proton.value) + } + for (comp in ComponentsManager.COMPONENTS) { + val value = gameProfile.components.getValue(comp) + val options: List> = + listOf(null to stringResource(R.string.comp_game_use_general)) + + snap.packages.filter { it.comp == comp }.map { it.file to it.version } + DrawerStackedChoice( + host = host, + key = "game-cmp-$comp", + label = ComponentsManager.LABEL.getValue(comp), + hint = if (value.inherited) { + stringResource(R.string.drawer_component_inherited, value.value) + } else { + null + }, + options = options, + selected = gameProfile.componentFiles[comp], + chipModifier = track("cmp-$comp"), + onPick = { file -> a.onGameComponent(gameProfile.appId, comp, file) }, + ) + } + } + Text( + stringResource(R.string.drawer_game_components_note, gameProfile.name), + fontSize = 12.sp, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp), + ) + return + } val running = snap.protons.filter { it.inUseByGame } var pick by rememberSaveable { mutableStateOf(null) } val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d621d8310..20f88d1fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,7 +58,7 @@ Save Remove Inherit again - Reset this profile + Clear environment overrides Unset for this profile No variables set. Inherited @@ -413,6 +413,41 @@ Check the Nightlies for new packages Proton The Proton chosen here runs every game that has not picked its own in Steam, and a change made in Steam shows here too. Steam replaces Valve\'s Proton when it updates; originals are kept per build. + Configuration + Configure profile + Profile + Default profile + Used automatically by games without overrides. + Saved settings used automatically when %1$s launches. + Choose only the settings this game needs. Everything else continues to use the Default profile. + FEX preset + Using the Default profile preset. + Custom preset saved for this game. + Custom FEX variables + Use Default + Game compatibility + Proton, DXVK, VKD3D and synchronization environment options for this game. + Using the Default profile Proton. + Custom Proton saved for this game through Steam compatibility mapping. + No Proton + DXVK + VKD3D-Proton + Uses the Default profile package from %1$s unless this game selects another package. + Uses the Default profile component package. + Uses %1$s only for this game. + Runtime options + Other per-game Proton, DXVK, VKD3D-Proton and synchronization environment options. + %1$s\'s profile is saved for the next game launch. + %1$s\'s Proton choice is saved and available in Steam now. + %1$s\'s Proton choice is saved. Restart Steam before launching the game. + %1$s cannot use the selected Proton on this device. + Could not load %1$s\'s profile: %2$s + The game profile could not be saved: %1$s + Reset game profile + Reset %1$s\'s profile? + Remove this game\'s Proton, FEX, component package, environment, texture filtering, and Windows component overrides? It will use the Default profile. + %1$s now uses the Default profile. + The game profile could not be reset completely: %1$s %1$s now runs every game that has not picked its own Proton in Steam. %1$s will run every game that has not picked its own Proton, from the next time Steam starts. %1$s cannot start games here, so it cannot be the default. @@ -576,6 +611,19 @@ Stop session? Running now No game is running on a Proton + Game profile + Profile details are not available yet. + Couldn\'t load game profiles. Retrying automatically. + Couldn\'t refresh game profiles. Showing the last available data; retrying automatically. + View and edit the saved settings used on the next launch. + Using Default profile + Custom profile + Using %1$s from the Default profile. + Changes are saved to %1$s\'s profile and apply the next time the game launches. + + %1$d game override + %1$d game overrides + No Proton is installed yet. game running re-applied at launch %1$s @@ -1257,6 +1305,7 @@ direct Swap failed: %1$s + Profile save failed: %1$s The desktop is paused %1$s is paused diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentGuestSelectionTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentGuestSelectionTest.kt new file mode 100644 index 000000000..7b5ee9480 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentGuestSelectionTest.kt @@ -0,0 +1,88 @@ +package com.droiddeck.launcher.agent + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AgentGuestSelectionTest { + private val installedAppIds = setOf(646570L, 3180310L) + + @Test fun focusedInstalledGameIsSelected() { + assertEquals(3180310L, AgentGuest.focusedInstalledSteamAppId(3180310L, installedAppIds)) + } + + @Test fun focusedInstalledGameIsActive() { + assertEquals( + 3180310L, + AgentGuest.activeInstalledSteamAppId(3180310L, listOf(769L, 3180310L), installedAppIds), + ) + } + + @Test fun selectedSteamGameWinsOverStaleActiveSteamGame() { + assertEquals( + 646570L, + AgentGuest.preferredInstalledSteamAppId(646570L, 3180310L, installedAppIds), + ) + } + + @Test fun validGamescopeGameFallsBackWhenSteamSelectionFails() { + assertEquals( + 3180310L, + AgentGuest.selectedOrActiveInstalledSteamAppId( + Result.failure(IllegalStateException("agent unavailable")), + 3180310L, + installedAppIds, + ), + ) + } + + @Test fun absentSelectionAndActiveGameRemainAValidNoSelection() { + assertNull(AgentGuest.selectedOrActiveInstalledSteamAppId(Result.success(null), null, installedAppIds)) + } + + @Test(expected = IllegalStateException::class) + fun selectionFailureWithoutGamescopeFallbackRemainsAnError() { + AgentGuest.selectedOrActiveInstalledSteamAppId( + Result.failure(IllegalStateException("agent unavailable")), + null, + installedAppIds, + ) + } + + @Test fun soleFocusableInstalledGameRemainsActiveUnderSteamOverlay() { + assertEquals( + 3180310L, + AgentGuest.activeInstalledSteamAppId(769L, listOf(3180310L), installedAppIds), + ) + } + + @Test fun ambiguousFocusableGamesAreIgnored() { + assertNull( + AgentGuest.activeInstalledSteamAppId(769L, listOf(646570L, 3180310L), installedAppIds), + ) + } + + @Test fun soleBaselayerGameResolvesAmbiguousSteamOverlayFocus() { + assertEquals( + 646570L, + AgentGuest.activeInstalledSteamAppId( + focusedAppId = 769L, + focusableAppIds = listOf(646570L, 3180310L), + installedAppIds = installedAppIds, + baselayerAppIds = listOf(769L, 646570L), + ), + ) + } + + @Test fun steamClientFocusIsIgnored() { + assertNull(AgentGuest.focusedInstalledSteamAppId(769L, installedAppIds)) + } + + @Test fun unknownGameFocusIsIgnored() { + assertNull(AgentGuest.focusedInstalledSteamAppId(42L, installedAppIds)) + } + + @Test fun missingFocusSnapshotIsIgnored() { + assertNull(AgentGuest.focusedInstalledSteamAppId(null, installedAppIds)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt index 82d5f3f64..cf428d878 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt @@ -102,4 +102,24 @@ class GameEnvironmentTest { assertTrue(GameEnvironment.validScope("4294967295")) for (scope in listOf("0", "-1", "01", "+1", "4294967296", "../42")) assertFalse(GameEnvironment.validScope(scope)) } + + @Test fun gameFexPresetOverridesGeneralAndCanInheritAgain() { + val performance = GameEnvironment.withGameFexPreset(GameEnvironment.Config(), "42", "PERFORMANCE") + assertEquals("PERFORMANCE", GameEnvironment.gameFexPreset(performance, "42")) + assertEquals("0", GameEnvironment.effective(performance, "STABILITY", "42")["FEX_TSOENABLED"]) + assertEquals("1", GameEnvironment.effective(performance, "STABILITY", "43")["FEX_TSOENABLED"]) + + val inherited = GameEnvironment.withGameFexPreset(performance, "42", null) + assertNull(GameEnvironment.gameFexPreset(inherited, "42")) + assertFalse(inherited.games.containsKey("42")) + } + + @Test fun gameFexPresetPreservesOtherOverridesAndRecognizesCustomValues() { + val original = GameEnvironment.Config(games = mapOf("42" to mapOf("CUSTOM" to "value", "FEX_MULTIBLOCK" to "7"))) + assertEquals(GameEnvironment.CUSTOM_FEX_PRESET, GameEnvironment.gameFexPreset(original, "42")) + + val compatibility = GameEnvironment.withGameFexPreset(original, "42", "COMPATIBILITY") + assertEquals("value", compatibility.games.getValue("42")["CUSTOM"]) + assertEquals("COMPATIBILITY", GameEnvironment.gameFexPreset(compatibility, "42")) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentsProcessTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentsProcessTest.kt new file mode 100644 index 000000000..f1a37e5f9 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentsProcessTest.kt @@ -0,0 +1,39 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ComponentsProcessTest { + private val guestPath = "/root/.local/share/Steam/steamapps/common/Proton" + private val hostPath = "/data/user/0/com.droiddeck.launcher/files/root/root/.local/share/Steam/steamapps/common/Proton" + + @Test + fun `running game is recognized through guest path`() { + val command = "python3 $guestPath/proton waitforexitandrun game.exe" + + assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true)) + } + + @Test + fun `running game is recognized through Android host path`() { + val command = "python3 $hostPath/proton waitforexitandrun game.exe" + + assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true)) + } + + @Test + fun `Steam startup Proton process is not reported as a running game`() { + val command = "python3 $hostPath/proton run wineboot.exe" + + assertFalse(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true)) + assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = false)) + } + + @Test + fun `unrelated Proton process is ignored`() { + val command = "python3 /other/Proton/proton waitforexitandrun game.exe" + + assertFalse(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt index 1ce0fc144..3f5e6e8e4 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt @@ -2,7 +2,10 @@ package com.droiddeck.launcher.session import android.content.Context import com.droiddeck.launcher.gpu.GpuInfo +import com.droiddeck.launcher.core.Hashes import com.droiddeck.launcher.runtime.LinuxRuntime +import com.github.luben.zstd.ZstdOutputStream +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.* @@ -89,6 +92,283 @@ class ForcedPackageTest { assertNull(install(gpu(650, GpuInfo.Family.A6XX))) } + @Test fun gameComponentProfilesReadOnlyKnownComponents() { + val state = File(context.filesDir, "components/state.json") + state.parentFile!!.mkdirs() + state.writeText(JSONObject() + .put("active", JSONObject()) + .put("queued", JSONObject()) + .put("profiles", JSONObject().put("42", JSONObject() + .put("fex", "fex.wcp") + .put("dxvk", "dxvk.wcp") + .put("unknown", "ignored.wcp"))) + .toString()) + + assertEquals(mapOf("fex" to "fex.wcp", "dxvk" to "dxvk.wcp"), ComponentsManager.gameComponents(context, "42")) + assertTrue(ComponentsManager.gameComponents(context, "43").isEmpty()) + } + + @Test fun removingLastGameOverrideKeepsBundledComponentRestoreRow() { + val protonName = "proton-exp" + proton(protonName) + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + + ComponentsManager.clearGameComponents(context, "42") + + assertFalse(ComponentsManager.gameComponents(context, "42").containsKey("dxvk")) + assertDefaultDxvkRestoreRow(protonName) + } + + @Test fun usingDefaultForLastGameOverrideKeepsBundledComponentRestoreRow() { + val protonName = "proton-exp" + proton(protonName) + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + + ComponentsManager.setGameComponent(context, "42", "dxvk", null) + + assertFalse(ComponentsManager.gameComponents(context, "42").containsKey("dxvk")) + assertDefaultDxvkRestoreRow(protonName) + } + + @Test fun aNewProtonIsCapturedWhenTheLastOverrideIsReset() { + proton("existing") + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + ComponentsManager.snapshotAll(context) + proton("new-proton") + + ComponentsManager.clearGameComponents(context, "42") + + assertDefaultDxvkRestoreRow("existing") + assertDefaultDxvkRestoreRow("new-proton") + } + + @Test fun anUpdatedBuildIsCapturedEvenAfterTheLastProfileWasRemoved() { + proton("proton-exp") + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + ComponentsManager.clearGameComponents(context, "42") + proton("proton-exp", "experimental-12") + + ComponentsManager.setGameComponent(context, "43", "dxvk", null) + + assertDefaultDxvkRestoreRow("proton-exp", "experimental-12") + assertTrue(original("proton-exp", "experimental-11").isFile) + assertTrue(original("proton-exp", "experimental-12").isFile) + } + + @Test fun aKnownEmptyComponentHasAnExplicitOriginalBundle() { + proton("proton-exp", dxvk = false) + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + + ComponentsManager.clearGameComponents(context, "42") + + assertTrue(original("proton-exp").isFile) + val rows = launchState().readLines().map { it.split('\t') } + assertEquals(14, rows.size) + assertTrue(rows.all { it[3] == "dxvk" && it[5] == "-" && it[6] == "-" }) + } + + @Test fun missingOriginalsCannotBeRecapturedFromPreviouslyProfiledFiles() { + proton("proton-exp") + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + val saved = File(context.filesDir, "components/state.json").readText() + val prior = "42\tproton-exp\texperimental-11\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t-\t-\n" + launchState().apply { parentFile!!.mkdirs(); writeText(prior) } + + assertThrows(IllegalStateException::class.java) { + ComponentsManager.setGameComponent(context, "42", "dxvk", null) + } + + assertEquals(saved, File(context.filesDir, "components/state.json").readText()) + assertEquals(prior, launchState().readText()) + assertFalse(original("proton-exp").isFile) + } + + @Test fun aBrokenOriginalDoesNotPublishDeletionRowsOrRemoveThePreviousStore() { + proton("proton-exp") + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + val state = File(context.filesDir, "components/state.json") + val saved = state.readText() + val prior = "*\tother\tbuild\tdxvk\tfiles/old.dll\told-hash\t/old.dll\n" + launchState().apply { parentFile!!.mkdirs(); writeText(prior) } + val oldSource = File(launchState().parentFile, "store/old/files/old.dll") + oldSource.apply { parentFile!!.mkdirs(); writeText("old component") } + original("proton-exp").apply { parentFile!!.mkdirs(); writeText("not a bundle") } + + assertThrows(java.io.IOException::class.java) { + ComponentsManager.clearGameComponents(context, "42") + } + + assertEquals(saved, state.readText()) + assertEquals(prior, launchState().readText()) + assertEquals("old component", oldSource.readText()) + } + + @Test fun anArchiveWithoutAProfileIsNotAKnownEmptyOriginal() { + proton("proton-exp") + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + val bundle = original("proton-exp") + bundle.parentFile!!.mkdirs() + TarArchiveOutputStream(ZstdOutputStream(bundle.outputStream())).use { } + + assertThrows(IllegalArgumentException::class.java) { + ComponentsManager.clearGameComponents(context, "42") + } + + assertFalse(launchState().exists()) + assertEquals("custom-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"]) + } + + @Test fun aFailedStateCommitIsRecoveredBeforeSelectionsCanBeRead() { + proton("proton-exp") + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + val state = File(context.filesDir, "components/state.json") + val saved = state.readText() + val blocked = File(context.filesDir, "components/state.json.tmp") + blocked.mkdir() + val blocker = File(blocked, "blocker").apply { writeText("keep directory nonempty") } + val oldSource = File(launchState().parentFile, "store/old/files/old.dll").apply { + parentFile!!.mkdirs() + writeText("old component") + } + + assertThrows(java.io.IOException::class.java) { + ComponentsManager.clearGameComponents(context, "42") + } + + assertEquals(saved, state.readText()) + assertTrue(pendingState().isFile) + assertTrue(launchState().isFile) + assertEquals(Hashes.sha256(launchState()), JSONObject(pendingState().readText()).getString("launchSha256")) + assertEquals("old component", oldSource.readText()) + assertThrows(java.io.IOException::class.java) { + ComponentsManager.gameComponents(context, "42") + } + assertTrue(pendingState().isFile) + + assertTrue(blocker.delete()) + assertTrue(blocked.delete()) + assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty()) + assertFalse(pendingState().exists()) + assertDefaultDxvkRestoreRow("proton-exp") + assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty()) + ComponentsManager.snapshotAll(context) + assertFalse(oldSource.exists()) + assertDefaultDxvkRestoreRow("proton-exp") + } + + @Test fun aFailedLaunchPublicationRetainsTheOldSelectionsAndStore() { + proton("proton-exp") + writeGameProfile("42", "dxvk", "custom-dxvk.wcp") + val state = File(context.filesDir, "components/state.json") + val saved = state.readText() + launchState().mkdirs() + val blocker = File(launchState(), "blocker").apply { writeText("block rename") } + val oldSource = File(launchState().parentFile, "store/old/files/old.dll").apply { + parentFile!!.mkdirs() + writeText("old component") + } + + assertThrows(IllegalStateException::class.java) { + ComponentsManager.clearGameComponents(context, "42") + } + + assertEquals(saved, state.readText()) + assertTrue(pendingState().isFile) + assertEquals("custom-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"]) + assertFalse(pendingState().exists()) + assertEquals("old component", oldSource.readText()) + assertTrue(blocker.delete()) + assertTrue(launchState().delete()) + + ComponentsManager.clearGameComponents(context, "42") + + assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty()) + assertFalse(oldSource.exists()) + assertDefaultDxvkRestoreRow("proton-exp") + } + + @Test fun interruptionBeforePublicationDiscardsOnlyThePendingSelections() { + writeGameProfile("42", "dxvk", "old-dxvk.wcp") + val state = File(context.filesDir, "components/state.json") + val saved = state.readText() + val next = JSONObject(saved).apply { + getJSONObject("profiles").getJSONObject("42").put("dxvk", "new-dxvk.wcp") + } + val staged = File(launchState().parentFile, "desired.tsv.tmp").apply { + parentFile!!.mkdirs() + writeText("new launch table") + } + launchState().writeText("old launch table") + writePendingState(next, staged) + + assertEquals("old-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"]) + assertEquals(saved, state.readText()) + assertEquals("old launch table", launchState().readText()) + assertFalse(pendingState().exists()) + assertFalse(staged.exists()) + } + + @Test fun identicalLaunchTablesDoNotCommitUnpublishedSettings() { + writeGameProfile("42", "dxvk", "old-dxvk.wcp") + val state = File(context.filesDir, "components/state.json") + val saved = state.readText() + val next = JSONObject(saved).put("queued", JSONObject().put("proton-exp", JSONObject())) + val staged = File(launchState().parentFile, "desired.tsv.tmp").apply { + parentFile!!.mkdirs() + writeText("unchanged launch table") + } + launchState().writeText(staged.readText()) + writePendingState(next, staged) + + assertEquals("old-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"]) + assertEquals(saved, state.readText()) + assertFalse(pendingState().exists()) + assertFalse(staged.exists()) + } + + @Test fun interruptionAfterPublicationRecoversAllSelectionsAndIsIdempotent() { + writeGameProfile("42", "dxvk", "old-dxvk.wcp") + val next = JSONObject() + .put("active", JSONObject().put("proton-exp", JSONObject().put("dxvk", JSONObject().put("file", dxvk2)))) + .put("queued", JSONObject().put("proton-exp", JSONObject().put("fex", JSONObject().put("file", "fex.wcp")))) + .put("profiles", JSONObject() + .put("42", JSONObject().put("dxvk", "new-dxvk.wcp")) + .put("43", JSONObject().put("fex", "fex.wcp"))) + .put("profiledComponents", JSONArray(listOf("dxvk", "fex"))) + launchState().apply { parentFile!!.mkdirs(); writeText("published launch table") } + writePendingState(next, launchState()) + + assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"]) + assertEquals("fex.wcp", ComponentsManager.gameComponents(context, "43")["fex"]) + assertEquals(next.toString(2), File(context.filesDir, "components/state.json").readText()) + assertFalse(pendingState().exists()) + assertEquals("published launch table", launchState().readText()) + assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"]) + } + + @Test fun aPendingTransactionAfterTheStateCommitCanBeRecoveredAgain() { + writeGameProfile("42", "dxvk", "new-dxvk.wcp") + val state = File(context.filesDir, "components/state.json") + val next = JSONObject(state.readText()) + launchState().apply { parentFile!!.mkdirs(); writeText("published launch table") } + writePendingState(next, launchState()) + + assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"]) + assertEquals(next.toString(2), state.readText()) + assertFalse(pendingState().exists()) + } + + @Test fun anUnreadableRecoveryRecordDoesNotSilentlyReturnStaleSelections() { + writeGameProfile("42", "dxvk", "old-dxvk.wcp") + pendingState().writeText("incomplete recovery record") + + assertThrows(org.json.JSONException::class.java) { + ComponentsManager.gameComponents(context, "42") + } + + assertTrue(pendingState().isFile) + } + @Test fun theSamePackageOnThisProtonBuildIsNotFetchedAgain() { proton("proton-exp") writeState("proton-exp", dxvk2, "experimental-11") @@ -204,11 +484,57 @@ class ForcedPackageTest { private fun refuseApply(protonId: String, file: String): String = error("apply $protonId $file") - private fun proton(name: String, version: String = "experimental-11") { + private fun writeGameProfile(scope: String, comp: String, file: String) { + val state = File(context.filesDir, "components/state.json") + state.parentFile!!.mkdirs() + state.writeText( + JSONObject() + .put("active", JSONObject()) + .put("queued", JSONObject()) + .put("profiles", JSONObject().put(scope, JSONObject().put(comp, file))) + .toString(), + ) + } + + private fun launchState() = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-components/desired.tsv") + + private fun pendingState() = File(context.filesDir, "components/state.pending.json") + + private fun writePendingState(state: JSONObject, launch: File) { + pendingState().writeText(JSONObject() + .put("state", state) + .put("launchSha256", Hashes.sha256(launch)) + .toString()) + } + + private fun original(protonName: String, version: String = "experimental-11") = + File(context.filesDir, "components/originals/$protonName/$version/dxvk.wcp") + + private fun assertDefaultDxvkRestoreRow(protonName: String, version: String = "experimental-11") { + val row = launchState().readLines().map { it.split('\t') }.single { + it.size == 7 && it[0] == "*" && it[1] == protonName && it[2] == version && + it[3] == "dxvk" && it[4].endsWith("/aarch64-windows/d3d11.dll") + } + assertNotEquals("-", row[6]) + val source = File(LinuxRuntime.rootDir(context), row[6].removePrefix("/")) + assertEquals("bundled DXVK $version", source.readText()) + assertEquals(Hashes.sha256(source), row[5]) + assertTrue(original(protonName, version).isFile) + val state = JSONObject(File(context.filesDir, "components/state.json").readText()) + assertEquals(listOf("dxvk"), state.getJSONArray("profiledComponents").let { values -> + (0 until values.length()).map(values::getString) + }) + } + + private fun proton(name: String, version: String = "experimental-11", dxvk: Boolean = true) { val dir = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/common/$name") File(dir, "files/lib/wine").mkdirs() File(dir, "proton").writeText("#!/bin/sh\n") File(dir, "version").writeText("1 $version\n") + if (dxvk) File(dir, "files/lib/wine/dxvk/aarch64-windows/d3d11.dll").apply { + parentFile!!.mkdirs() + writeText("bundled DXVK $version") + } } private fun writeState(protonId: String, file: String, protonVersion: String) { diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt index 07cbc6efe..d499437e0 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt @@ -69,6 +69,91 @@ class GameEnvironmentStoreTest { SessionState.upscaleRatio = 0f } + @Test fun changingGameFexPresetPreservesDefaultAndOtherGameSettings() { + SessionPrefs.setFexPreset(context, "PERFORMANCE_TSO") + val original = GameEnvironment.withGameFexPreset( + GameEnvironment.Config( + shared = mapOf("PROTON_LOG" to "1"), + games = mapOf("42" to mapOf("DXVK_HUD" to "fps")), + ), + "43", + "COMPATIBILITY", + ) + GameEnvironmentStore.save(context, original) + + GameEnvironmentStore.setGameFexPreset(context, "42", "STABILITY") + + val saved = GameEnvironmentStore.read(context) + assertEquals("STABILITY", GameEnvironment.gameFexPreset(saved, "42")) + assertEquals("fps", saved.entries("42")["DXVK_HUD"]) + assertEquals(original.shared, saved.shared) + assertEquals(original.entries("43"), saved.entries("43")) + assertEquals("PERFORMANCE_TSO", SessionPrefs.fexPreset(context)) + val published = GameEnvironmentStore.decode(JSONObject(guest.readText())) + assertEquals(saved.games, published.games) + assertEquals("0", published.shared["FEX_HALFBARRIERTSOENABLED"]) + assertEquals("1", published.entries("42")["FEX_HALFBARRIERTSOENABLED"]) + } + + @Test fun gameFexDefaultsAndInheritanceRemainDistinct() { + SessionPrefs.setFexPreset(context, "EXTREME") + val original = GameEnvironment.Config( + games = mapOf("42" to mapOf("DXVK_HUD" to "fps")), + ) + GameEnvironmentStore.save(context, original) + + GameEnvironmentStore.setGameFexPreset(context, "42", "") + + val explicitDefaults = GameEnvironmentStore.read(context) + assertEquals("", GameEnvironment.gameFexPreset(explicitDefaults, "42")) + val published = GameEnvironmentStore.decode(JSONObject(guest.readText())) + assertTrue(published.entries("42").containsKey("FEX_SMCCHECKS")) + assertNull(published.entries("42")["FEX_SMCCHECKS"]) + + GameEnvironmentStore.setGameFexPreset(context, "42", null) + + assertEquals(original, GameEnvironmentStore.read(context)) + val inherited = GameEnvironmentStore.decode(JSONObject(guest.readText())) + assertFalse(inherited.entries("42").containsKey("FEX_SMCCHECKS")) + assertEquals("none", inherited.shared["FEX_SMCCHECKS"]) + assertEquals("EXTREME", SessionPrefs.fexPreset(context)) + } + + @Test fun invalidGamePresetEditsCannotChangeSavedOrPublishedSettings() { + val original = GameEnvironment.Config(shared = mapOf("PROTON_LOG" to "1")) + GameEnvironmentStore.save(context, original) + val published = guest.readText() + + assertThrows(IllegalArgumentException::class.java) { + GameEnvironmentStore.setGameFexPreset(context, "", "STABILITY") + } + assertThrows(IllegalArgumentException::class.java) { + GameEnvironmentStore.setGameFexPreset(context, "42", GameEnvironment.CUSTOM_FEX_PRESET) + } + + assertEquals(original, GameEnvironmentStore.read(context)) + assertEquals(published, guest.readText()) + } + + @Test fun targetedFexUpdateDoesNotRestoreEnvironmentClearedByACompletedReset() { + val original = GameEnvironment.withGameFexPreset( + GameEnvironment.Config(games = mapOf("42" to mapOf("DXVK_HUD" to "fps"))), + "42", + "COMPATIBILITY", + ) + GameEnvironmentStore.save(context, original) + val staleEditorSnapshot = GameEnvironmentStore.read(context) + val resetEnvironment = staleEditorSnapshot.withEntries("42", emptyMap()) + GameEnvironmentStore.save(context, resetEnvironment) + + GameEnvironmentStore.setGameFexPreset(context, "43", "PERFORMANCE_TSO") + + val saved = GameEnvironmentStore.read(context) + assertTrue(saved.entries("42").isEmpty()) + assertEquals("PERFORMANCE_TSO", GameEnvironment.gameFexPreset(saved, "43")) + assertFalse(saved.games.containsKey("42")) + } + @Test fun unchosenFexPresetPublishesPerformanceTso() { GameEnvironmentStore.save(context, GameEnvironment.Config()) val shared = JSONObject(guest.readText()).getJSONObject("shared") diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameProfileFollowStateTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameProfileFollowStateTest.kt new file mode 100644 index 000000000..c24379fae --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameProfileFollowStateTest.kt @@ -0,0 +1,17 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GameProfileFollowStateTest { + @Test fun reopeningGamesPageResumesFollowingSteamAfterManualInspection() { + val state = GameProfileFollowState() + + state.selectManually() + assertFalse(state.followsSteam) + + state.resumeFollowingSteam() + assertTrue(state.followsSteam) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameProfileInventoryCacheTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameProfileInventoryCacheTest.kt new file mode 100644 index 000000000..914a7e094 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameProfileInventoryCacheTest.kt @@ -0,0 +1,103 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class GameProfileInventoryCacheTest { + @Test + fun loadsOnlyOncePerRevision() { + val cache = GameProfileInventoryCache() + var loads = 0 + + assertEquals(listOf("one"), cache.get(0) { loads++; listOf("one") }) + assertEquals(listOf("one"), cache.get(0) { loads++; listOf("two") }) + assertEquals(1, loads) + + assertEquals(listOf("two"), cache.get(1) { loads++; listOf("two") }) + assertEquals(2, loads) + } + + @Test + fun failedLoadIsRetriedInsteadOfCached() { + val cache = GameProfileInventoryCache() + var loads = 0 + + assertThrows(IllegalStateException::class.java) { + cache.get(0) { loads++; error("inventory unavailable") } + } + + assertEquals(listOf("recovered"), cache.get(0) { loads++; listOf("recovered") }) + assertEquals(2, loads) + } + + @Test + fun failedNewRevisionDoesNotReuseThePreviousInventory() { + val cache = GameProfileInventoryCache() + assertEquals(listOf("old"), cache.get(0) { listOf("old") }) + + assertThrows(IllegalStateException::class.java) { + cache.get(1) { error("inventory unavailable") } + } + + assertEquals(listOf("new"), cache.get(1) { listOf("new") }) + assertEquals(listOf("new"), cache.get(1) { error("should use cached inventory") }) + } + + @Test + fun emptyInventoryIsCachedUntilTheRevisionChanges() { + val cache = GameProfileInventoryCache() + + assertEquals(emptyList(), cache.get(0) { emptyList() }) + assertEquals(emptyList(), cache.get(0) { error("should use cached inventory") }) + assertEquals(listOf("installed"), cache.get(1) { listOf("installed") }) + } + + @Test(timeout = 10_000) + fun reopeningDuringAScanQueuesFreshInventoryWithoutWaitingAndRejectsOldResults() { + data class Refresh(val appId: Long, val inventoryRevision: Long) + + val cache = GameProfileInventoryCache() + val coordinator = LatestRequestCoordinator() + val scanStarted = CountDownLatch(1) + val finishScan = CountDownLatch(1) + val worker = Executors.newSingleThreadExecutor() + try { + assertTrue(coordinator.submit(Refresh(42, 0))) + val first = coordinator.takeLatest()!! + val oldInventory = worker.submit> { + cache.get(first.value.inventoryRevision) { + scanStarted.countDown() + check(finishScan.await(5, TimeUnit.SECONDS)) + listOf("old") + } + } + assertTrue(scanStarted.await(5, TimeUnit.SECONDS)) + + assertFalse(coordinator.submit(Refresh(42, 1))) + assertFalse(oldInventory.isDone) + assertFalse(coordinator.isLatest(first)) + + finishScan.countDown() + assertEquals(listOf("old"), oldInventory.get(5, TimeUnit.SECONDS)) + assertFalse(coordinator.isLatest(first)) + val reopened = coordinator.takeLatest()!! + val newInventory = worker.submit> { + cache.get(reopened.value.inventoryRevision) { listOf("new") } + } + assertEquals(listOf("new"), newInventory.get(5, TimeUnit.SECONDS)) + assertTrue(coordinator.isLatest(reopened)) + assertNull(coordinator.takeLatest()) + } finally { + finishScan.countDown() + worker.shutdownNow() + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameProfileManagerTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameProfileManagerTest.kt new file mode 100644 index 000000000..77b2895c3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameProfileManagerTest.kt @@ -0,0 +1,103 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class GameProfileManagerTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + File(context.filesDir, "game-environment.json").delete() + File(context.filesDir, "wincomponents.json").delete() + File(context.filesDir, "components").deleteRecursively() + File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat").deleteRecursively() + } + + @Test + fun resetClearsOnlyTheSelectedGameProfile() { + val game = "42" + val other = "43" + GameEnvironmentStore.save( + context, + GameEnvironment.Config( + games = mapOf( + game to mapOf("DXVK_HUD" to "fps"), + other to mapOf("DXVK_HUD" to "compiler"), + ), + ), + ) + ProtonDefault.requestGame(context, game, proton("game")) + ProtonDefault.requestGame(context, other, proton("other")) + seedComponentProfiles(game, other) + SessionPrefs.setGameTextureAnisotropy(context, game, 4) + SessionPrefs.setGameTextureAnisotropy(context, other, 8) + WinComponents.setPicks(context, game, listOf("vcrun2022")) + WinComponents.setPicks(context, other, listOf("dotnet48")) + + GameProfileManager.reset(context, game) + + val environment = GameEnvironmentStore.read(context) + assertFalse(environment.games.containsKey(game)) + assertEquals("compiler", environment.entries(other)["DXVK_HUD"]) + assertNull(ProtonDefault.gameChoice(context, game)) + assertEquals("other", ProtonDefault.gameChoice(context, other)?.dir) + assertEquals(emptyMap(), ComponentsManager.gameComponents(context, game)) + assertEquals("other.wcp", ComponentsManager.gameComponents(context, other)["dxvk"]) + assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, game)) + assertEquals(8, SessionPrefs.gameTextureFiltering(context, other).anisotropy) + assertEquals(emptyList(), WinComponents.picks(context, game)) + assertEquals(listOf("dotnet48"), WinComponents.picks(context, other)) + } + + @Test + fun resetRejectsEmptyScopeWithoutChangingSharedSettings() { + GameEnvironmentStore.save( + context, + GameEnvironment.Config(shared = mapOf("DXVK_HUD" to "fps")), + ) + + assertThrows(IllegalArgumentException::class.java) { + GameProfileManager.reset(context, "") + } + assertEquals("fps", GameEnvironmentStore.read(context).shared["DXVK_HUD"]) + } + + private fun proton(name: String) = ComponentsManager.Proton( + id = name, + name = name, + dir = File(context.filesDir, name), + guestPath = "/proton/$name", + version = "1", + valve = false, + ) + + private fun seedComponentProfiles(game: String, other: String) { + val components = File(context.filesDir, "components").apply { mkdirs() } + File(components, "state.json").writeText( + JSONObject().put( + "profiles", + JSONObject() + .put(game, JSONObject().put("dxvk", "game.wcp")) + .put(other, JSONObject().put("dxvk", "other.wcp")), + ).toString(), + ) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameProfileRefreshStateTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameProfileRefreshStateTest.kt new file mode 100644 index 000000000..49d073e8a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameProfileRefreshStateTest.kt @@ -0,0 +1,48 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GameProfileRefreshStateTest { + @Test + fun firstFailureKeepsInitialStateWithoutCachedData() { + val state = GameProfileRefreshState().failed() + + assertTrue(state.failed) + assertFalse(state.loaded) + assertNull(state.data) + } + + @Test + fun failureAfterSuccessPreservesCachedData() { + val state = GameProfileRefreshState().succeeded("cached profiles").failed() + + assertTrue(state.failed) + assertTrue(state.loaded) + assertEquals("cached profiles", state.data) + } + + @Test + fun successAfterFailureReplacesCachedDataAndClearsWarning() { + val state = GameProfileRefreshState() + .succeeded("old profiles") + .failed() + .succeeded("new profiles") + + assertFalse(state.failed) + assertTrue(state.loaded) + assertEquals("new profiles", state.data) + } + + @Test + fun retryingClearsWarningWithoutDiscardingCachedData() { + val state = GameProfileRefreshState().succeeded("cached profiles").failed().retrying() + + assertFalse(state.failed) + assertTrue(state.loaded) + assertEquals("cached profiles", state.data) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/LatestRequestCoordinatorTest.kt b/app/src/test/java/com/droiddeck/launcher/session/LatestRequestCoordinatorTest.kt new file mode 100644 index 000000000..12e7397b8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/LatestRequestCoordinatorTest.kt @@ -0,0 +1,68 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LatestRequestCoordinatorTest { + @Test fun coalescesPendingRequestsToTheLatestValue() { + val coordinator = LatestRequestCoordinator() + + assertTrue(coordinator.submit("first")) + val first = coordinator.takeLatest()!! + assertFalse(coordinator.submit("second")) + assertFalse(coordinator.submit("third")) + + assertFalse(coordinator.isLatest(first)) + val latest = coordinator.takeLatest()!! + assertEquals("third", latest.value) + assertTrue(coordinator.isLatest(latest)) + assertNull(coordinator.takeLatest()) + } + + @Test fun requestAfterWorkerBecomesIdleStartsAnotherWorker() { + val coordinator = LatestRequestCoordinator() + + assertTrue(coordinator.submit("first")) + coordinator.takeLatest() + assertNull(coordinator.takeLatest()) + + assertTrue(coordinator.submit("second")) + assertEquals("second", coordinator.takeLatest()!!.value) + } + + @Test fun newerRequestInvalidatesCompletedResultBeforeItIsApplied() { + val coordinator = LatestRequestCoordinator() + + assertTrue(coordinator.submit("first")) + val completed = coordinator.takeLatest()!! + assertFalse(coordinator.submit("second")) + + assertFalse(coordinator.isLatest(completed)) + assertEquals("second", coordinator.takeLatest()!!.value) + } + + @Test fun profileSwitchInvalidatesAnOlderFailedRefresh() { + val coordinator = LatestRequestCoordinator() + + assertTrue(coordinator.submit(42L)) + val failedRefresh = coordinator.takeLatest()!! + assertFalse(coordinator.submit(43L)) + + assertFalse(coordinator.isLatest(failedRefresh)) + assertEquals(43L, coordinator.takeLatest()!!.value) + } + + @Test fun repeatedPollDoesNotInvalidateEquivalentInFlightResult() { + val coordinator = LatestRequestCoordinator() + + assertTrue(coordinator.submit("same")) + val inFlight = coordinator.takeLatest()!! + assertFalse(coordinator.submit("same")) + + assertTrue(coordinator.isLatest(inFlight)) + assertEquals("same", coordinator.takeLatest()!!.value) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt index ce830cca8..fcf85ddb9 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.session +import com.droiddeck.launcher.runtime.LinuxRuntime import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -7,6 +8,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment import java.io.File @RunWith(RobolectricTestRunner::class) @@ -59,4 +61,46 @@ class ProtonDefaultTest { assertNull(ProtonDefault.chosen(ProtonDefault.parseState("{"), ProtonDefault.parseRequest("[]"))) assertNull(ProtonDefault.parseRequest("""{"seq": 0, "dir": "x"}""")) } + + @Test + fun perGameChoicesPreserveCustomizeAndInheritSemantics() { + val games = ProtonDefault.parseGames( + """{"version":1,"games":{"42":{"valve":false,"dir":"GE-Proton11-7"},"43":null,"0":null,"bad":{},"44":{"valve":"false","dir":"GE-Proton9-1"}}}""", + ) + assertEquals(ProtonDefault.GameChoice(false, "GE-Proton11-7"), games["42"]) + assertTrue(games.containsKey("43")) + assertNull(games["43"]) + assertFalse(games.containsKey("0")) + assertFalse(games.containsKey("bad")) + assertFalse(games.containsKey("44")) + assertTrue(ProtonDefault.parseGames("""{"version":2,"games":{"42":null}}""").isEmpty()) + } + + @Test + fun perGameRequestsPersistCustomizeThenInherit() { + val context = RuntimeEnvironment.getApplication() + val file = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat/games.json") + file.parentFile?.deleteRecursively() + val selected = proton("GE-Proton11-7", false) + + ProtonDefault.requestGame(context, "42", selected) + assertEquals(ProtonDefault.GameChoice(false, "GE-Proton11-7"), ProtonDefault.parseGames(file.readText())["42"]) + + ProtonDefault.requestGame(context, "42", null) + val inherited = ProtonDefault.parseGames(file.readText()) + assertTrue(inherited.containsKey("42")) + assertNull(inherited["42"]) + } + + @Test + fun missingPerGameProtonIdentityRemainsStoredButUnresolved() { + val context = RuntimeEnvironment.getApplication() + val file = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat/games.json") + file.parentFile?.deleteRecursively() + ProtonDefault.requestGame(context, "42", proton("GE-Proton9-1", false)) + + assertEquals(ProtonDefault.GameChoice(false, "GE-Proton9-1"), ProtonDefault.gameChoice(context, "42")) + assertNull(ProtonDefault.gameSelectedId(context, "42", listOf(proton("GE-Proton11-7", false)))) + assertEquals(ProtonDefault.GameChoice(false, "GE-Proton9-1"), ProtonDefault.parseGames(file.readText())["42"]) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SelectedGameProfileTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SelectedGameProfileTest.kt new file mode 100644 index 000000000..99209674f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SelectedGameProfileTest.kt @@ -0,0 +1,128 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.frontend.Library +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class SelectedGameProfileTest { + private val proton = ComponentsManager.Proton("p1", "Proton 10", File("p1"), "/p1", "10", true) + private val snapshot = ComponentsManager.Snapshot( + protons = listOf( + ComponentsManager.ProtonView( + proton = proton, + components = mapOf( + "fex" to component("FEX General"), + "dxvk" to component("DXVK General"), + "vkd3d" to component("VKD3D General"), + ), + originals = emptyList(), + inUseByGame = false, + ), + ), + packages = listOf(ComponentsManager.Package("dxvk.wcp", "dxvk", "DXVK Game", "", 1)), + ) + private val game = Library.SteamGame(3180310, "Billies Wheelie", null, "internal") + + @Test fun inheritedValuesUseGeneralProfile() { + val profile = resolve() + + assertEquals("Billies Wheelie", profile!!.name) + assertEquals("PERFORMANCE_TSO", profile.fexPreset.value) + assertTrue(profile.fexPreset.inherited) + assertEquals("Proton 10", profile.proton.value) + assertTrue(profile.proton.inherited) + assertEquals("p1", profile.protonId) + assertEquals("DXVK General", profile.components.getValue("dxvk").value) + assertTrue(profile.components.getValue("dxvk").inherited) + assertEquals(emptyMap(), profile.componentFiles) + assertEquals(0, profile.environmentOverrides) + assertEquals(emptyList(), profile.windowsComponentNames) + assertFalse(profile.hasOverrides) + } + + @Test fun explicitValuesOverrideGeneralProfile() { + val preset = GameEnvironment.withGameFexPreset(GameEnvironment.Config(), game.profileKey, "STABILITY") + val environment = preset.withEntries(game.profileKey, preset.entries(game.profileKey) + ("PROTON_LOG" to "1")) + val profile = resolve( + environment = environment, + gameChoice = ProtonDefault.GameChoice(true, "p1"), + gameComponents = mapOf("dxvk" to "dxvk.wcp"), + ) + + assertEquals("STABILITY", profile!!.fexPreset.value) + assertFalse(profile.fexPreset.inherited) + assertFalse(profile.proton.inherited) + assertEquals("DXVK Game", profile.components.getValue("dxvk").value) + assertFalse(profile.components.getValue("dxvk").inherited) + assertEquals("dxvk.wcp", profile.componentFiles.getValue("dxvk")) + assertEquals(1, profile.environmentOverrides) + assertTrue(profile.hasOverrides) + } + + @Test fun windowsComponentsMakeProfileCustom() { + val profile = resolve(gameWindowsComponents = listOf("oalinst_dll", "vcredist2010_dll")) + + assertEquals(listOf("OpenAL", "Visual C++ 2010"), profile!!.windowsComponentNames) + assertTrue(profile.hasOverrides) + } + + @Test fun unknownSelectedAppIsIgnored() { + assertNull(resolve(selectedAppId = 42)) + } + + @Test fun switchingProfilesResolvesEachGamesFexPresetWithoutChangingSettings() { + val other = Library.SteamGame(42, "Other game", null, "internal") + val games = listOf(game, other) + val environment = GameEnvironment.withGameFexPreset( + GameEnvironment.Config(), game.profileKey, "STABILITY", + ) + + assertEquals(ProfileValue("STABILITY", false), + resolve(games = games, environment = environment)!!.fexPreset) + assertEquals(ProfileValue("PERFORMANCE_TSO", true), + resolve(selectedAppId = 42, games = games, environment = environment)!!.fexPreset) + assertEquals(ProfileValue("STABILITY", false), + resolve(games = games, environment = environment)!!.fexPreset) + assertNull(GameEnvironment.gameFexPreset(environment, other.profileKey)) + } + + @Test fun customAndExplicitDefaultFexPresetsAreNotShownAsInherited() { + val custom = GameEnvironment.Config( + games = mapOf(game.profileKey to mapOf("FEX_TSOENABLED" to "0")), + ) + assertEquals(ProfileValue(GameEnvironment.CUSTOM_FEX_PRESET, false), + resolve(environment = custom)!!.fexPreset) + val defaults = GameEnvironment.withGameFexPreset(custom, game.profileKey, "") + assertEquals(ProfileValue("", false), resolve(environment = defaults)!!.fexPreset) + val inherited = GameEnvironment.withGameFexPreset(defaults, game.profileKey, null) + assertEquals(ProfileValue("PERFORMANCE_TSO", true), resolve(environment = inherited)!!.fexPreset) + } + + private fun resolve( + selectedAppId: Long = 3180310, + games: List = listOf(game), + environment: GameEnvironment.Config = GameEnvironment.Config(), + gameChoice: ProtonDefault.GameChoice? = null, + gameComponents: Map = emptyMap(), + gameWindowsComponents: List = emptyList(), + ) = SelectedGameProfile.resolve( + selectedAppId = selectedAppId, + games = games, + generalFexPreset = "PERFORMANCE_TSO", + environment = environment, + snapshot = snapshot, + generalProtonId = "p1", + gameProtonChoice = gameChoice, + gameProtonId = "p1", + gameComponents = gameComponents, + gameWindowsComponents = gameWindowsComponents, + ) + + private fun component(label: String) = + ComponentsManager.Component(label, "", "Changed outside Components", label, null, null) +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/TextureFilteringPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/TextureFilteringPrefsTest.kt new file mode 100644 index 000000000..c59cd52fd --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/TextureFilteringPrefsTest.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.core.TextureFiltering +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class TextureFilteringPrefsTest { + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + File(context.filesDir, "game-environment.json").delete() + File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json").delete() + } + + @Test fun gameChoicesRemainSeparateFromDefaultAndCanInheritAgain() { + SessionPrefs.setTextureAnisotropy(context, 16) + SessionPrefs.setTextureLodBias(context, TextureFiltering.LOD_BIAS_AUTO) + SessionPrefs.setGameTextureAnisotropy(context, "42", 4) + SessionPrefs.setGameTextureLodBias(context, "42", "-0.5") + + assertEquals(16, SessionPrefs.textureAnisotropy(context)) + assertEquals(TextureFiltering.LOD_BIAS_AUTO, SessionPrefs.textureLodBias(context)) + assertEquals(SessionPrefs.GameTextureFiltering(4, "-0.5"), SessionPrefs.gameTextureFiltering(context, "42")) + assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "43")) + + SessionPrefs.setGameTextureAnisotropy(context, "42", null) + SessionPrefs.setGameTextureLodBias(context, "42", null) + + assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "42")) + assertFalse(SessionPrefs.gameTextureFiltering(context).containsKey("42")) + } + + @Test fun publishedDxvkOptionsUseGameOverridesWithoutChangingDefault() { + SessionPrefs.setTextureAnisotropy(context, 16) + SessionPrefs.setGameTextureAnisotropy(context, "42", 0) + SessionPrefs.setGameTextureLodBias(context, "42", TextureFiltering.LOD_BIAS_OFF) + + val published = JSONObject( + File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json").readText(), + ) + assertTrue(published.getString(GameEnvironmentStore.DXVK_CONFIG).contains("samplerAnisotropy = 16")) + assertEquals("", published.getJSONObject(GameEnvironmentStore.DXVK_CONFIG_GAMES).getString("42")) + assertNull(SessionPrefs.gameTextureFiltering(context, "43").anisotropy) + } + + @Test fun clearingGameChoicesPreservesOtherProfiles() { + SessionPrefs.setGameTextureAnisotropy(context, "42", 4) + SessionPrefs.setGameTextureLodBias(context, "42", "-0.5") + SessionPrefs.setGameTextureAnisotropy(context, "43", 8) + + SessionPrefs.clearGameTextureFiltering(context, "42") + + assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "42")) + assertEquals(8, SessionPrefs.gameTextureFiltering(context, "43").anisotropy) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt new file mode 100644 index 000000000..e01d884da --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt @@ -0,0 +1,59 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.Library +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class ComponentsPageProfileTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun gameProfilesCoexistWithTheDefaultComponentStack() { + val context = RuntimeEnvironment.getApplication() + compose.setContent { + DroidDeckTheme { + ComponentsPage( + snapshot = null, + catalog = emptyList(), + catalogAt = 0, + protonId = null, + comp = PROTON_TAB, + checking = false, + busy = null, + downloads = emptyMap(), + onProton = {}, + onComp = {}, + onSwap = {}, + onRestore = {}, + onCancelQueued = {}, + onDeletePackage = {}, + onDeleteOriginal = {}, + onDownload = {}, + onRefresh = {}, + onImport = {}, + onBack = {}, + games = listOf(Library.SteamGame(42, "Game A", null, "internal")), + requestInitialFocus = false, + ) + } + } + val stackHeading = context.getString(R.string.comp_this_proton).uppercase() + compose.onNodeWithText(stackHeading).assertExists() + compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick() + compose.onNodeWithText("Game A").performClick() + compose.onNodeWithText(context.getString(R.string.comp_game_hint)).assertExists() + compose.onNodeWithText(stackHeading).assertDoesNotExist() + + compose.onNodeWithText("Game A").performClick() + compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick() + compose.onNodeWithText(stackHeading).assertExists() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GameEnvironmentEditorTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GameEnvironmentEditorTest.kt new file mode 100644 index 000000000..14bd5afaf --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GameEnvironmentEditorTest.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.ui + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.session.GameEnvironmentStore +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class GameEnvironmentEditorTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun backgroundSelectionCannotRetargetAnOpenVariableEdit() { + val context: Context = RuntimeEnvironment.getApplication() + val variable = "MESA_SHADER_CACHE_MAX_SIZE" + GameEnvironmentStore.save(context, GameEnvironment.Config(games = mapOf( + "42" to mapOf(variable to "512M"), + "43" to mapOf(variable to "2G"), + ))) + val selectedScope = mutableStateOf("42") + compose.setContent { + DroidDeckTheme { GameEnvironmentRow(initialScope = selectedScope.value) } + } + compose.onNodeWithText(context.getString(R.string.game_env_edit)).performClick() + compose.waitUntil(5_000) { + compose.onAllNodes(hasText("512M")).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText("512M").performScrollTo().performClick() + compose.onNodeWithText(context.getString(R.string.game_env_change)).performClick() + compose.onAllNodesWithText("512M").onLast().performClick() + compose.onNodeWithText("4G").performClick() + + compose.runOnIdle { selectedScope.value = "43" } + compose.onNodeWithText(context.getString(R.string.game_env_save)).performClick() + compose.waitUntil(5_000) { + GameEnvironmentStore.read(context).entries("42")[variable] == "4G" + } + assertEquals("2G", GameEnvironmentStore.read(context).entries("43")[variable]) + compose.onNodeWithText("4G").assertExists() + + compose.onNodeWithText(context.getString(R.string.game_env_done)).performScrollTo().performClick() + compose.waitForIdle() + compose.onNodeWithText(context.getString(R.string.game_env_edit)).performClick() + compose.waitUntil(5_000) { + compose.onAllNodes(hasText("2G")).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText("2G").assertExists() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt b/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt new file mode 100644 index 000000000..12c382f5d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.ui + +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createComposeRule +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +fun createRobolectricComposeRule() = createComposeRule().also { + val context = RuntimeEnvironment.getApplication() + // Register only in Robolectric, without adding a host activity to either APK variant. + shadowOf(context.packageManager).addActivityIfNotPresent( + ComponentName(context, ComponentActivity::class.java), + ) +} diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md index 223915adc..2bbf28c78 100644 --- a/docs/development/game-environment.md +++ b/docs/development/game-environment.md @@ -3,16 +3,52 @@ Open **Games → Game environment**, directly below **FEX preset**, in Steam settings or the in-session drawer. Choose all Proton games, an installed game, or enter a Steam app ID. For a non-Steam shortcut, use its numeric `compatdata` directory ID (unsigned 32-bit). +The drawer opens the editor on its selected game profile. That editing scope stays +fixed if Steam's selected game changes in the background; use the editor's own +profile picker to switch deliberately. Changes apply on the next game launch, including when Steam is already running. A running game must be restarted. Native Linux games and Steam itself are outside this editor's scope. +The Components page also has a **Configuration** dropdown. **Default profile** owns the +default Proton and FEX preset. An installed game can inherit those defaults or +choose its own Proton and FEX preset. Proton choices are applied through Steam's +per-app compatibility mapping, including while Steam is running when its live +compatibility helper is attached. A newly installed Proton that the running +client has not discovered yet applies when Steam next starts. + +Each game can also override the stored FEX, DXVK and VKD3D-Proton package used +by its selected Proton. The launch wrapper applies that game’s component files +immediately before Proton starts, removes managed files that are absent from the +selection, and restores Default component choices for the next game. **Use +Default** removes the game override. Component packages must first be downloaded +or imported on the Default Components page. + +Before publishing game component choices, DroidDeck preserves each Proton build's +original components, including an explicitly empty bundle when a component is not +shipped. Those restore choices remain after the last override is cleared. A missing +or unreadable original is an error, not permission to delete the component's files. +If an original is missing after a build may already have used a game override, +DroidDeck refuses to capture those potentially modified files as originals. + +Component selections and the launch table are saved with a recovery record. +Publishing the launch table commits the selection: if the app stops before its +settings file is updated, the next component read recovers the matching settings. +An interruption before publication leaves the previous selections in place. +Unused staged packages are removed only after both files have been saved. + +**Reset game profile** removes all overrides owned by the selected game: Proton, +FEX preset, component packages, environment variables, texture filtering and +Windows components. The game then inherits the Default profile again. + The order is inherited process environment, selected FEX preset and built-in settings, shared edits, then game-specific edits. Editor entries therefore win over matching Steam launch-option variables. **Remove** explicitly unsets a -variable. **Restore inherited settings** removes the override. **Reset this -profile** clears only that profile's overrides. Values are literal strings, with -no shell expansion; quotes are only needed when the consuming program expects them. +variable. **Restore inherited settings** removes the override. The environment +editor's **Clear environment overrides** action clears only the selected +environment entries and is intentionally narrower than **Reset game profile**. +Values are literal strings, with no shell expansion; quotes are only needed when +the consuming program expects them. ## Defaults and available suggestions @@ -71,14 +107,18 @@ wrapper and adopted third-party Proton wrappers execute `droiddeck-game-env`, which reads that snapshot for each real game launch and uses `execvpe` to start Proton. Probe prefix `compatdata/0` and non-launch verbs are unchanged. Malformed configuration falls back to the inherited environment without evaluating its -contents. Signed non-Steam prefix IDs are normalized to unsigned IDs. +contents. Both component and environment selection normalize signed non-Steam +prefix IDs to unsigned IDs. -The published file also carries a `dxvkConfig` string the app's own file never -has: the session menu's texture filtering (Effects page; `core/TextureFiltering`) -as `d3d9/d3d11.samplerAnisotropy` and `samplerLodBias` options. The launcher -appends it to `DXVK_CONFIG` after the profiles, so a user's own `DXVK_CONFIG` -entry keeps its options. "Auto" texture sharpness is `-log2(panel / session)`, -derived when the session is sized, and applies from the next launch. +The published file also carries generated `dxvkConfig` and `dxvkConfigGames` +values the app's own file never has. The session menu's texture filtering +(`core/TextureFiltering`) becomes `d3d9/d3d11.samplerAnisotropy` and +`samplerLodBias` options. Default choices apply to games without overrides; each +game can save either choice independently or return it to **Use Default**. The +launcher appends the selected options to `DXVK_CONFIG` after the environment +profile, so a user's own `DXVK_CONFIG` entry keeps its options. "Auto" texture +sharpness is `-log2(panel / session)`, derived when the session is sized, and +applies from the next launch. Upstream references: [VKD3D capability parsing](https://github.com/HansKristian-Work/vkd3d-proton/blob/master/libs/vkd3d/device.c), [VKD3D options](https://github.com/HansKristian-Work/vkd3d-proton#environment-variables), diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 0df6d5363..b0e8efcfd 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -453,6 +453,34 @@ def cdp_eval(agent_dir, expression, target=None, await_promise=True, timeout=15) ws.close() +def steam_selection(agent_dir, timeout=5): + result = cdp_eval( + agent_dir, + """(() => { + let node = document.activeElement; + while (node) { + for (const name of ["data-id", "data-appid"]) { + const value = node.getAttribute?.(name); + if (/^[1-9][0-9]{0,9}$/.test(value || "")) { + const appId = Number(value); + if (Number.isSafeInteger(appId) && appId <= 4294967295) return value; + } + } + node = node.parentElement; + } + return null; + })()""", + target="Steam Big Picture Mode", + timeout=timeout, + ) + value = result.get("value") + if value is None: + return {"appId": None} + if not isinstance(value, str) or not value.isdigit() or not 0 < int(value) <= 4294967295: + raise AgentError("INVALID_SELECTION", "Steam returned an invalid selected app id") + return {"appId": int(value)} + + # --- Game processes ------------------------------------------------------------------------------ def game_pids(app_id): @@ -624,6 +652,8 @@ def handle(agent_dir, req): raise AgentError("INVALID_REQUEST", "cdp eval needs an expression") return cdp_eval(agent_dir, expression, req.get("target"), bool(req.get("awaitPromise", True)), request_timeout(req, 15)) + if kind == "steam-selection": + return steam_selection(agent_dir, request_timeout(req, 5)) if kind == "quit": return quit_game(agent_dir, req.get("appId"), request_timeout(req, 15)) raise AgentError("UNKNOWN_REQUEST", "Unknown agent request '%s'" % kind) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 792a7676a..260b6f937 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -168,7 +168,10 @@ def apply_config(env, config, app_id, fixes=None): raise ValueError("invalid environment value") # The app's texture filtering (Display page), as DXVK options appended after the profiles so # a DXVK_CONFIG entry of the user's keeps its own. - dxvk = config.get("dxvkConfig") + game_dxvk = config.get("dxvkConfigGames", {}) + if not isinstance(game_dxvk, dict): + raise ValueError("invalid game dxvk configuration") + dxvk = game_dxvk.get(app_id, config.get("dxvkConfig")) if dxvk is not None: if not isinstance(dxvk, str) or "\0" in dxvk or len(dxvk) > 8192: raise ValueError("invalid dxvk configuration") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 88f66e1eb..31c06358d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -447,10 +447,15 @@ class Helper: self.installed = installed picks = COMPAT["valve_picks"](self.steam_root) apps = sorted(set(installed) | set(picks) | set(state.get("auto", {})), key=COMPAT["app_order"]) + overrides = COMPAT["requested_games"]( + COMPAT["game_requests"](), catalog, [t["name"] for t in live]) + apps = sorted(set(apps) | set(overrides), key=COMPAT["app_order"]) current = dict(page.compat("many(%s)" % json.dumps(apps)) or {}) current["0"] = top["selected"] native = COMPAT["native_targets"](self.steam_root, installed) - changes, auto = COMPAT["plan_mapping"](current, installed, [t["name"] for t in live], default, COMPAT["followers"](state, current), native, picks) + changes, auto = COMPAT["plan_mapping"]( + current, installed, [t["name"] for t in live], default, + COMPAT["followers"](state, current), native, picks, overrides) for app, tool in sorted(changes.items(), key=lambda item: COMPAT["app_order"](item[0])): page.compat("set(%d, %s)" % (int(app), json.dumps(tool))) say("%s -> %s" % ("default" if app == "0" else "app %s" % app, tool)) @@ -489,7 +494,7 @@ class Helper: if self.page.ws in ready: due = any(called(e, BINDING) for e in self.drain()) if (watcher in ready or watcher.fileno() < 0) and watcher.changed(): - due = self.installed != COMPAT["installed_apps"](self.steam_root) or self.request_waiting() + due = True if due: time.sleep(0.3) if watcher.fileno() >= 0 and select.select([watcher], [], [], 0)[0]: @@ -519,7 +524,8 @@ def main(): say("no devtools port for this session; the client's mapping is kept through config.vdf") return 0 os.makedirs(COMPAT["COMPAT_DIR"], exist_ok=True) - watcher = Watcher([COMPAT["state_path"]("request.json")] + [os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")]) + watcher = Watcher([COMPAT["state_path"]("request.json"), COMPAT["state_path"]("games.json")] + + [os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")]) # The client has its own SteamOS manager when started as SteamOS (droiddeck-session's Deck mode). steamos = os.environ.get("BL_STEAMDECK") == "1" and os.environ.get("BL_STEAM_UI") != "desktop" helper = Helper(steam_root, port, steamos) diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 18dc6bb12..ab205ce27 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -92,10 +92,12 @@ TOOL_MANIFEST_VDF = """"manifest" # relies on compatdata/0 itself. The launch-time check below is the protection instead. # The Components page's choices, enforced in the one place every game launch passes: the app keeps -# desired.tsv (tool dir, Proton build, component, file, sha256, source copy) and an unpacked copy of -# each package in use. A file that no longer matches - whatever changed it - is put back before -# Proton starts. Only for the Proton build the choice was made for: after a Steam update the build -# differs and the Proton's own new files are left alone until the user chooses again. +# desired.tsv (scope, tool dir, Proton build, component, file, sha256, source copy) and an unpacked +# copy of each package in use. A game's rows replace General rows for that component. A `-` source +# removes a managed file absent from the selected package, so one game's package cannot leak into +# the next game. A file that no longer matches - whatever changed it - is put back before Proton +# starts. Only the matching Proton build is changed; after a Steam update the build differs and the +# Proton's own new files are left alone until the user chooses again. # A store game (GOG, Epic, Amazon) is started through the launcher .bat DroidDeck wrote beside it. # An Epic game needs a fresh sign-in code, which only the app can mint: droiddeck-store-launch asks # for it and the app writes it into the game's folder, so a launch from the client's own Play @@ -149,21 +151,56 @@ bl_components() { [ "${verb:-}" = waitforexitandrun ] || return 0 case "${STEAM_COMPAT_DATA_PATH:-}" in ''|*/compatdata/0|*/compatdata/0/|*/compatdata/0-*) return 0 ;; esac [ -f "$cm_list" ] || return 0 - cm_app=${STEAM_COMPAT_DATA_PATH%/} + cm_app=${STEAM_COMPAT_DATA_PATH} + while [[ "$cm_app" == */ ]]; do cm_app=${cm_app%/}; done cm_app=${cm_app##*/} + if [[ "$cm_app" =~ ^(-?)([0-9]+)$ ]]; then + cm_sign=${BASH_REMATCH[1]} + cm_digits=${BASH_REMATCH[2]} + while [[ ${#cm_digits} -gt 1 && "$cm_digits" == 0* ]]; do cm_digits=${cm_digits#0}; done + if [[ ${#cm_digits} -le 10 ]]; then + cm_app=$((10#$cm_digits)) + [ "$cm_sign" = "-" ] && cm_app=$((-cm_app)) + if (( cm_app >= -2147483648 && cm_app <= 4294967295 && cm_app != 0 )); then + (( cm_app < 0 )) && cm_app=$((cm_app + 4294967296)) + else + cm_app="" + fi + else + cm_app="" + fi + else + cm_app="" + fi + if [ -z "$cm_app" ]; then + echo "droiddeck-proton: components: invalid game prefix, left as it is" >&2 + return 0 + fi cm_tool=${base##*/} cm_build=$(awk 'NR == 1 { print $2 }' "$base/version" 2>/dev/null) cm_fixed=0 cm_checked=0 + cm_selected=$(awk -F '\\t' -v app="$cm_app" -v tool="$cm_tool" -v build="$cm_build" ' + $2 == tool && $3 == build { + rows[++count] = $0 + scopes[count] = $1 + comps[count] = $4 + if ($1 == app) specific[$4] = 1 + } + END { + for (i = 1; i <= count; i++) + if (scopes[i] == app || (scopes[i] == "*" && !specific[comps[i]])) print rows[i] + } + ' "$cm_list") # One sha256sum for every file: each program started here pays a dozen traced library lookups # under the game's search path before it does anything. local cm_rows=() cm_files=() row h p local -A cm_sums=() - while IFS=$'\\t' read -r t want comp rel sum src; do - [ "$t" = "$cm_tool" ] && [ "$want" = "$cm_build" ] || continue + while IFS=$'\\t' read -r scope t want comp rel sum src; do + [ -n "$scope" ] || continue cm_rows+=("$comp"$'\\t'"$rel"$'\\t'"$sum"$'\\t'"$src") - [ -f "$base/$rel" ] && cm_files+=("$base/$rel") - done < "$cm_list" + [ "$src" != "-" ] && [ -f "$base/$rel" ] && cm_files+=("$base/$rel") + done <<< "$cm_selected" if [ ${#cm_files[@]} -gt 0 ]; then while read -r h p; do cm_sums["$p"]=$h; done < <(sha256sum -- "${cm_files[@]}" 2>/dev/null) fi @@ -171,6 +208,10 @@ bl_components() { IFS=$'\\t' read -r comp rel sum src <<< "$row" cm_checked=$((cm_checked + 1)) f="$base/$rel" + if [ "$src" = "-" ]; then + if [ -e "$f" ]; then rm -f "$f" && cm_fixed=$((cm_fixed + 1)); fi + continue + fi if [ -f "$f" ] && [ "${cm_sums[$f]:-}" = "$sum" ]; then continue; fi if [ ! -f "$src" ]; then echo "droiddeck-proton: components: $comp source missing ($src), left as it is" >&2 @@ -985,16 +1026,30 @@ def app_order(app): return (len(app), app) -def plan_mapping(current, installed, runnable, default, auto, native=None, picks=()): +DEFERRED_GAME_TOOL = object() + + +def plan_mapping(current, installed, runnable, default, auto, native=None, picks=(), overrides=None): runnable = {n.lower() for n in runnable} installed = set(installed) picks = set(picks) native = native or {} + overrides = overrides or {} changes, kept = {}, {} if current.get("0", "") != default: changes["0"] = default - for app in sorted((set(current) | installed | picks | set(auto)) - {"0"}, key=app_order): + for app in sorted((set(current) | installed | picks | set(auto) | set(overrides)) - {"0"}, key=app_order): name = current.get(app, "") + if app in overrides: + target = overrides[app] + if target is DEFERRED_GAME_TOOL: + continue + target = target or default + if name != target: + changes[app] = target + if overrides[app] is None: + kept[app] = target + continue mine = auto.get(app) target = native.get(app, default) stale = unrunnable(name, runnable) or (app in native and linux_runtime_tool(name)) @@ -1053,6 +1108,34 @@ def requested_tool(request, catalog): return None +def game_requests(directory=None): + value = read_json(state_path("games.json", directory)) + if value.get("version") != 1 or not isinstance(value.get("games"), dict): + return {} + return value["games"] + + +def requested_games(requests, catalog, runnable=None): + """App id -> requested tool, None for inherit, or DEFERRED_GAME_TOOL while unavailable.""" + runnable = None if runnable is None else set(runnable) + resolved = {} + for app, request in requests.items(): + if not isinstance(app, str) or not re.fullmatch(r"[1-9][0-9]*", app) or int(app) > 0xFFFFFFFF: + continue + if request is None: + resolved[app] = None + continue + if (not isinstance(request, dict) or not isinstance(request.get("dir"), str) or + not request["dir"] or not isinstance(request.get("valve"), bool)): + continue + tool = requested_tool(request, catalog) + if tool is None or runnable is not None and tool not in runnable: + resolved[app] = DEFERRED_GAME_TOOL + else: + resolved[app] = tool + return resolved + + def now_ms(): import time return int(time.time() * 1000) @@ -1174,7 +1257,9 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non else: default = state.get("default") if state.get("default") in names else TOOL steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path))) - changes, auto = plan_mapping(current, apps, names, default, followers(state, current), native_targets(steam_root, apps), picks) + overrides = requested_games(game_requests(directory), catalog) + changes, auto = plan_mapping( + current, apps, names, default, followers(state, current), native_targets(steam_root, apps), picks, overrides) for app, tool in changes.items(): set_mapping(tokens, mapping, app, tool) text = serialize(tokens) diff --git a/tools/tests/test_droiddeck_agent.py b/tools/tests/test_droiddeck_agent.py index c04f2eeb8..8cc103e35 100644 --- a/tools/tests/test_droiddeck_agent.py +++ b/tools/tests/test_droiddeck_agent.py @@ -208,6 +208,22 @@ class AgentTest(unittest.TestCase): missing = self.ask({"kind": "cdp", "expression": "1", "target": "Nope"}) self.assertEqual("NO_TARGET", missing["error"]["code"]) + def test_steam_selection(self): + with patch.object(agent, "cdp_eval", return_value={"value": "3180310"}) as evaluate: + answer = self.ask({"kind": "steam-selection"}) + self.assertEqual(3180310, answer["appId"]) + self.assertIn("document.activeElement", evaluate.call_args.args[1]) + self.assertEqual("Steam Big Picture Mode", evaluate.call_args.kwargs["target"]) + + def test_steam_selection_allows_no_highlight(self): + with patch.object(agent, "cdp_eval", return_value={"value": None}): + self.assertIsNone(self.ask({"kind": "steam-selection"})["appId"]) + + def test_steam_selection_rejects_invalid_app_id(self): + with patch.object(agent, "cdp_eval", return_value={"value": "4294967296"}): + answer = self.ask({"kind": "steam-selection"}) + self.assertEqual("INVALID_SELECTION", answer["error"]["code"]) + def test_cdp_exception(self): server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": { "text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}}) diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 817038c7a..d6a2ee749 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -1,3 +1,4 @@ +import hashlib import json import os import shutil @@ -52,6 +53,72 @@ class GameEnvironmentTest(unittest.TestCase): self.assertEqual(env["REMOVE"], "inherited") self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared") + def test_component_profiles_override_general_and_restore_it_for_the_next_game(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "home" + component_dir = home / ".local/share/droiddeck-components" + base = root / "proton" + target = base / "files/lib/wine/dxvk/aarch64-windows/d3d11.dll" + leftover = target.with_name("d3d8.dll") + component_dir.mkdir(parents=True) + target.parent.mkdir(parents=True) + (base / "version").write_text("1 build-1\n") + general = root / "general.dll" + game = root / "game.dll" + general.write_text("general") + game.write_text("game") + target.write_text("old") + leftover.write_text("old") + digest = lambda path: hashlib.sha256(path.read_bytes()).hexdigest() + (component_dir / "desired.tsv").write_text( + "*\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t%s\t%s\n" + "42\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t%s\t%s\n" + "42\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d8.dll\t-\t-\n" + % (digest(general), general, digest(game), game) + ) + script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n' + env = dict(os.environ, HOME=str(home), STEAM_COMPAT_DATA_PATH="/steam/compatdata/42") + subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True) + self.assertEqual(target.read_text(), "game") + self.assertFalse(leftover.exists()) + + env["STEAM_COMPAT_DATA_PATH"] = "/steam/compatdata/43" + subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True) + self.assertEqual(target.read_text(), "general") + + def test_component_scope_matches_environment_scope_for_shortcut_ids(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + component_dir = root / ".local/share/droiddeck-components" + base = root / "proton" + target = base / "files/lib/wine/dxvk/aarch64-windows/d3d11.dll" + component_dir.mkdir(parents=True) + target.parent.mkdir(parents=True) + (base / "version").write_text("1 build-1\n") + rows = [] + for scope in ("*", "42", "4294967295", "2147483648"): + source = root / ("default" if scope == "*" else scope) + source.write_text(scope) + rows.append("%s\tproton\tbuild-1\tdxvk\t%s\t%s\t%s\n" % ( + scope, target.relative_to(base), + hashlib.sha256(source.read_bytes()).hexdigest(), source, + )) + (component_dir / "desired.tsv").write_text("".join(rows)) + script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n' + for value in ( + "42", "00042", "43", "-1", "-0001", "4294967295", "-2147483648", + "2147483648", "-1///", "0", "-0", "000", "-2147483649", "4294967296", + "999999999999999999999999999", "not-an-id", "1+1", "", + ): + with self.subTest(prefix=value): + env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/" + value) + target.write_text("untouched") + subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True) + scope = MODULE["game_id"](env) + expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*" + self.assertEqual(target.read_text(), expected) + def test_engine_fixes_follow_the_games_files(self): with tempfile.TemporaryDirectory() as tmp: game = Path(tmp) / "Godot Game" @@ -109,6 +176,22 @@ class GameEnvironmentTest(unittest.TestCase): with self.assertRaises(ValueError): MODULE["apply_config"]({}, {**config, "dxvkConfig": bad}, "42") + def test_game_texture_filtering_overrides_default_without_leaking(self): + default = "d3d9.samplerAnisotropy = 16" + game = "d3d9.samplerAnisotropy = 4" + config = { + "version": 1, + "shared": {}, + "games": {}, + "dxvkConfig": default, + "dxvkConfigGames": {"42": game, "43": ""}, + } + self.assertEqual(MODULE["apply_config"]({}, config, "42")["DXVK_CONFIG"], game) + self.assertNotIn("DXVK_CONFIG", MODULE["apply_config"]({}, config, "43")) + self.assertEqual(MODULE["apply_config"]({}, config, "44")["DXVK_CONFIG"], default) + with self.assertRaises(ValueError): + MODULE["apply_config"]({}, {**config, "dxvkConfigGames": []}, "42") + def test_game_ids_and_probes(self): for prefix in ("", "/compatdata/0", "/compatdata/0-123", "/compatdata/nope", "/compatdata/4294967296"): self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix})) @@ -353,6 +436,57 @@ class ProtonDefaultTest(unittest.TestCase): changes, auto = self.plan({"0": self.TOOL}, [], self.TOOL, {"10": self.TOOL}) self.assertEqual((changes, auto), ({}, {})) + def test_per_game_proton_override_and_inherit_follow_steam_mappings(self): + current = {"0": self.TOOL, "10": self.TOOL, "11": "GE-Proton11-7", "12": self.TOOL} + overrides = {"10": "GE-Proton11-7", "11": None} + changes, auto = COMPAT["plan_mapping"]( + current, ["10", "11", "12"], self.NAMES, COMPAT["TOOL_11"], + {"10": self.TOOL, "12": self.TOOL}, overrides=overrides) + self.assertEqual(changes, {"0": COMPAT["TOOL_11"], "10": "GE-Proton11-7", "11": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]}) + self.assertEqual(auto, {"11": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]}) + + def test_per_game_requests_resolve_identity_and_defer_a_new_live_tool(self): + requests = { + "10": {"valve": False, "dir": "GE-Proton11-7"}, + "11": None, + "12": {"valve": True, "dir": "Proton 11.0 (ARM64)"}, + "0": None, + } + self.assertEqual( + COMPAT["requested_games"](requests, self.CATALOG), + {"10": "GE-Proton11-7", "11": None, "12": COMPAT["TOOL_11"]}, + ) + self.assertEqual( + COMPAT["requested_games"](requests, self.CATALOG, [self.TOOL, COMPAT["TOOL_11"]]), + {"10": COMPAT["DEFERRED_GAME_TOOL"], "11": None, "12": COMPAT["TOOL_11"]}, + ) + + def test_missing_per_game_proton_is_preserved_during_default_registration(self): + with tempfile.TemporaryDirectory() as tmp: + config = Path(tmp) / "config.vdf" + config.write_text(config_text([ + ("0", self.TOOL), + ("10", "GE-Proton9-1"), + ("11", self.TOOL), + ])) + (Path(tmp) / "games.json").write_text(json.dumps({ + "version": 1, + "games": { + "10": {"valve": False, "dir": "GE-Proton9-1"}, + }, + })) + + COMPAT["register_default"]( + str(config), ["10", "11"], default=COMPAT["TOOL_11"], + catalog=self.CATALOG, directory=tmp, + ) + + self.assertEqual(mapping_of(config.read_text()), { + "0": COMPAT["TOOL_11"], + "10": "GE-Proton9-1", + "11": COMPAT["TOOL_11"], + }) + def test_the_last_side_to_change_wins(self): state = {"default": self.TOOL, "applied": 5} default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 5, "dir": "Proton 11.0 (ARM64)", "valve": True}, self.CATALOG, now=100) diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py index a20ffba2f..dfe76ce67 100644 --- a/tools/tests/test_steam_compat_live.py +++ b/tools/tests/test_steam_compat_live.py @@ -208,6 +208,33 @@ class ReconcileTest(unittest.TestCase): self.assertEqual((state["pending"], state.get("applied", 0)), (TOOL_11, 0)) self.assertTrue(self.helper.request_waiting()) + def test_per_game_customize_and_inherit_apply_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL_11}, [TOOL, TOOL_11]) + (self.state / "games.json").write_text(json.dumps({ + "version": 1, + "games": { + "10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}, + "11": None, + }, + })) + state = self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL_11), ("11", TOOL)]) + self.assertEqual(state["auto"], {"11": TOOL}) + + def test_missing_per_game_proton_stays_deferred_live(self): + page = FakePage({"0": TOOL, "10": "GE-Proton9-1", "11": TOOL_11}, [TOOL, TOOL_11]) + (self.state / "games.json").write_text(json.dumps({ + "version": 1, + "games": { + "10": {"valve": False, "dir": "GE-Proton9-1"}, + "11": None, + }, + })) + state = self.reconcile(page) + self.assertEqual(page.sets, [("11", TOOL)]) + self.assertEqual(page.mapping["10"], "GE-Proton9-1") + self.assertEqual(state["auto"], {"11": TOOL}) + def test_file_mode_defers_to_the_live_helper(self): (self.state / "live.pid").write_text("%d\n" % os.getpid()) self.assertTrue(COMPAT["live_helper_running"]()) From 0cafa83ea7569132d96c180dcf96487e672a56d1 Mon Sep 17 00:00:00 2001 From: Gustash Date: Sun, 11 Oct 2026 22:37:47 +0100 Subject: [PATCH 5/6] fix: Epic games with a JSON manifest download (#578) --- .../stores/epic/EpicDownloadManager.java | 33 +++++-- app/src/main/rust/stores/src/epic/manifest.rs | 86 +++++++++++++++---- .../stores/epic/EpicJsonManifestTest.kt | 55 ++++++++++++ 3 files changed, 148 insertions(+), 26 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/epic/EpicJsonManifestTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java index 5d178e74e..ad78a04be 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java @@ -548,10 +548,10 @@ public final class EpicDownloadManager { try { JSONObject root = new JSONObject(new String(bytes, StandardCharsets.UTF_8)); int manifestVersion = 0; - try { manifestVersion = Integer.parseInt(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {} + try { manifestVersion = (int) blobToNum(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {} Manifest m = new Manifest(); m.chunkDir = manifestVersion >= 15 ? "ChunksV4" : manifestVersion >= 6 ? "ChunksV3" : manifestVersion >= 3 ? "ChunksV2" : "ChunksV4"; - m.launchExe = root.optString("LaunchExe", "").replace('\\', '/'); + m.launchExe = root.optString("LaunchExeString", root.optString("LaunchExe", "")).replace('\\', '/'); m.launchCommand = root.optString("LaunchCommand", ""); m.buildVersion = root.optString("BuildVersionString", ""); m.prereqName = root.optString("PrereqName", ""); @@ -568,10 +568,9 @@ public final class EpicDownloadManager { if (guidHex.length() < 32) continue; ChunkInfo c = new ChunkInfo(); for (int k = 0; k < 4; k++) c.guid[k] = (int) Long.parseLong(guidHex.substring(k * 8, k * 8 + 8), 16); - String hashHex = chunkHashList.getString(guidHex); - if (hashHex != null && hashHex.length() >= 16) { try { c.hash = Long.parseUnsignedLong(hashHex.substring(0, 16), 16); } catch (Exception ignored) {} } - if (dataGroupList != null) { try { c.groupNum = Integer.parseInt(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} } - if (chunkFilesizeList != null) { try { c.fileSize = Long.parseLong(chunkFilesizeList.optString(guidHex, "0"), 16); } catch (NumberFormatException ignored) {} } + try { c.hash = blobToNum(chunkHashList.getString(guidHex)); } catch (NumberFormatException ignored) {} + if (dataGroupList != null) { try { c.groupNum = (int) blobToNum(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} } + if (chunkFilesizeList != null) { try { c.fileSize = blobToNum(chunkFilesizeList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} } chunkMap.put(guidHex, c); } JSONArray fileList = root.optJSONArray("FileManifestList"); @@ -589,8 +588,8 @@ public final class EpicDownloadManager { ChunkPart part = new ChunkPart(); String g = po.optString("Guid", ""); if (g.length() >= 32) for (int k = 0; k < 4; k++) part.guid[k] = (int) Long.parseLong(g.substring(k * 8, k * 8 + 8), 16); - try { part.offset = Integer.parseInt(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {} - try { part.size = Integer.parseInt(po.optString("Size", "0")); } catch (NumberFormatException ignored) {} + try { part.offset = (int) blobToNum(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {} + try { part.size = (int) blobToNum(po.optString("Size", "0")); } catch (NumberFormatException ignored) {} fi.parts.add(part); } files.add(fi); @@ -604,6 +603,24 @@ public final class EpicDownloadManager { } } + /** + * A JSON manifest writes every number as a "blob": each byte as three decimal digits, least + * significant first ("013000000000" = 13), as Legendary's blob_to_num reads it. Bits past 64 + * are dropped; a group that is not digits throws. + */ + static long blobToNum(String blob) { + long num = 0; + for (int i = 0, shift = 0; i < blob.length(); i += 3, shift += 8) { + String group = blob.substring(i, Math.min(i + 3, blob.length())); + for (int k = 0; k < group.length(); k++) { + char ch = group.charAt(k); + if (ch < '0' || ch > '9') throw new NumberFormatException("not a blob: " + blob); + } + if (shift < 64) num += Long.parseLong(group) << shift; + } + return num; + } + public static String readFString(ByteBuffer buf) { int length = buf.getInt(); if (length == 0) return ""; diff --git a/app/src/main/rust/stores/src/epic/manifest.rs b/app/src/main/rust/stores/src/epic/manifest.rs index 579a56c32..7fbe834ea 100644 --- a/app/src/main/rust/stores/src/epic/manifest.rs +++ b/app/src/main/rust/stores/src/epic/manifest.rs @@ -4,7 +4,7 @@ //! Every quirk of the Java reader is kept on purpose (the Java manager stays the reference and //! the two engines must derive the same plan from the same bytes): section skips by declared //! size, `readFString` UTF-16LE/ASCII decoding, duplicate-GUID handling (last value, first -//! position), the JSON `Long.parseLong(.., 16)` / `parseInt` fallbacks, and "any exception → +//! position), the JSON blob decoding (`blobToNum`) and its fallbacks, and "any exception → //! parse failed". use std::collections::HashMap; @@ -445,6 +445,25 @@ fn json_to_string(v: &serde_json::Value) -> String { } } +/// `EpicDownloadManager.blobToNum`: a JSON manifest writes every number as a "blob", each byte +/// as three decimal digits, least significant first ("013000000000" = 13). Bits past 64 are +/// dropped; `None` when a group is not a number (Java: `NumberFormatException`). +pub fn blob_to_num(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut num: u64 = 0; + for (i, group) in bytes.chunks(3).enumerate() { + if !group.iter().all(u8::is_ascii_digit) { + return None; + } + let byte: u64 = std::str::from_utf8(group).ok()?.parse().ok()?; + let shift = i * 8; + if shift < 64 { + num = num.wrapping_add(byte << shift); + } + } + Some(num) +} + /// `obj.optString(key, fallback)`. fn opt_string(obj: &serde_json::Map, key: &str, fallback: &str) -> String { match obj.get(key) { @@ -483,9 +502,8 @@ pub fn parse_json_manifest(bytes: &[u8]) -> Result { .as_object() .ok_or_else(|| "JSON manifest: root is not an object".to_string())?; - let manifest_version: i32 = opt_string(root, "ManifestFileVersion", "0") - .parse::() - .unwrap_or(0); + let manifest_version: i32 = blob_to_num(&opt_string(root, "ManifestFileVersion", "0")) + .map_or(0, |v| v as i32); let chunk_dir = chunk_dir_for_json_version(manifest_version); let chunk_hash_list = root @@ -502,19 +520,14 @@ pub fn parse_json_manifest(bytes: &[u8]) -> Result { if guid_hex.len() < 32 || !guid_hex.is_char_boundary(32) { continue; } - let hash_hex = json_to_string(hash_value); let guid = parse_guid_from_hex(guid_hex)?; - let hash = if hash_hex.len() >= 16 && hash_hex.is_char_boundary(16) { - u64::from_str_radix(&hash_hex[0..16], 16).unwrap_or(0) - } else { - 0 - }; + let hash = blob_to_num(&json_to_string(hash_value)).unwrap_or(0); let group_num = match data_group_list { - Some(dgl) => opt_string(dgl, guid_hex, "0").parse::().unwrap_or(0), + Some(dgl) => blob_to_num(&opt_string(dgl, guid_hex, "0")).map_or(0, |v| v as i32), None => 0, }; let file_size = match chunk_filesize_list { - Some(cfl) => i64::from_str_radix(&opt_string(cfl, guid_hex, "0"), 16).unwrap_or(0), + Some(cfl) => blob_to_num(&opt_string(cfl, guid_hex, "0")).map_or(0, |v| v as i64), None => 0, }; let c = ChunkInfo { @@ -570,8 +583,8 @@ pub fn parse_json_manifest(bytes: &[u8]) -> Result { if part_guid.len() >= 32 && part_guid.is_char_boundary(32) { part.guid = parse_guid_from_hex(&part_guid)?; } - part.offset = opt_string(part_obj, "Offset", "0").parse::().unwrap_or(0); - part.size = opt_string(part_obj, "Size", "0").parse::().unwrap_or(0); + part.offset = blob_to_num(&opt_string(part_obj, "Offset", "0")).map_or(0, |v| v as i32); + part.size = blob_to_num(&opt_string(part_obj, "Size", "0")).map_or(0, |v| v as i32); fi.parts.push(part); } } @@ -928,17 +941,29 @@ mod tests { assert_eq!(c.fstring().unwrap(), "a\u{FFFD}b"); } + #[test] + fn blob_numbers_are_decimal_bytes_least_significant_first() { + assert_eq!(blob_to_num("001000000000"), Some(1)); + assert_eq!(blob_to_num("000001000000"), Some(0x100)); + assert_eq!(blob_to_num("001002003004005006007008"), Some(0x0807_0605_0403_0201)); + assert_eq!(blob_to_num("255255255255255255255255"), Some(u64::MAX)); + assert_eq!(blob_to_num("7"), Some(7)); + assert_eq!(blob_to_num(""), Some(0)); + assert_eq!(blob_to_num("0x1"), None); + assert_eq!(blob_to_num("-05"), None); + } + #[test] fn json_manifest_parses_like_java() { let json = r#"{ "ManifestFileVersion": "013", "ChunkHashList": { - "0000000100000002000000030000000ff": "0123456789ABCDEF00", + "0000000100000002000000030000000ff": "001002003004005006007008", "short": "00", "DEADBEEFFFFFFFFF0000000080000000": "zz" }, "DataGroupList": { "0000000100000002000000030000000ff": "7", "DEADBEEFFFFFFFFF0000000080000000": "x" }, - "ChunkFilesizeList": { "0000000100000002000000030000000ff": "FF" }, + "ChunkFilesizeList": { "0000000100000002000000030000000ff": "255" }, "FileManifestList": [ { "Filename": "a.bin", "InstallTags": ["German", ""], "FileChunkParts": [ { "Guid": "0000000100000002000000030000000ff", "Offset": "12", "Size": "34" }, @@ -951,7 +976,7 @@ mod tests { assert_eq!(m.unique_chunks.len(), 2, "keys shorter than 32 chars are skipped"); let c0 = &m.unique_chunks[0]; assert_eq!(c0.guid, [1, 2, 3, 0x0000_000F]); - assert_eq!(c0.hash, 0x0123_4567_89AB_CDEF); + assert_eq!(c0.hash, 0x0807_0605_0403_0201); assert_eq!(c0.group_num, 7); assert_eq!(c0.file_size, 0xFF); assert_eq!(c0.window_size, 0); @@ -968,11 +993,36 @@ mod tests { assert_eq!(m.files[0].parts[0].size, 34); assert_eq!(m.files[0].parts[1].guid, [0; 4]); assert_eq!(m.files[0].parts[1].offset, 0); - assert_eq!(m.files[0].parts[1].size, -5); + assert_eq!(m.files[0].parts[1].size, 0); assert!(m.files[0].sha1.is_none()); assert!(m.files[1].parts.is_empty()); } + #[test] + fn json_manifest_uses_blobs() { + let json = r#"{ + "ManifestFileVersion": "006000000000", + "ChunkHashList": { "0000000100000002000000030000000F": "001002003004005006007008" }, + "DataGroupList": { "0000000100000002000000030000000F": "007" }, + "ChunkFilesizeList": { "0000000100000002000000030000000F": "001002000000000000000000" }, + "FileManifestList": [ + { "Filename": "a.bin", + "FileChunkParts": [ { "Guid": "0000000100000002000000030000000F", "Offset": "001000000000", "Size": "000000016000" } ] } + ] + }"#; + let m = parse_manifest(json.as_bytes()).expect("json parse"); + assert_eq!(m.chunk_dir, "ChunksV3"); + let c = &m.unique_chunks[0]; + assert_eq!(c.file_size, 0x0201); + assert_eq!( + c.path(&m.chunk_dir), + "ChunksV3/07/0807060504030201_0000000100000002000000030000000F.chunk" + ); + let part = &m.files[0].parts[0]; + assert_eq!(part.offset, 1); + assert_eq!(part.size, 1_048_576); + } + #[test] fn json_manifest_failures_match_java_nulls() { assert!(parse_manifest(br#"{"FileManifestList": []}"#).is_err(), "no ChunkHashList"); diff --git a/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicJsonManifestTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicJsonManifestTest.kt new file mode 100644 index 000000000..4d085013f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/epic/EpicJsonManifestTest.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.stores.epic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The JSON manifest's numbers are blobs; the same values as the Rust engine's manifest tests. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class EpicJsonManifestTest { + @Test + fun blobNumbersAreDecimalBytesLeastSignificantFirst() { + assertEquals(1L, EpicDownloadManager.blobToNum("001000000000")) + assertEquals(0x100L, EpicDownloadManager.blobToNum("000001000000")) + assertEquals(0x0807_0605_0403_0201L, EpicDownloadManager.blobToNum("001002003004005006007008")) + assertEquals(-1L, EpicDownloadManager.blobToNum("255255255255255255255255")) + assertEquals(7L, EpicDownloadManager.blobToNum("7")) + assertEquals(0L, EpicDownloadManager.blobToNum("")) + } + + @Test + fun aBlobIsDigitsOnly() { + assertThrows(NumberFormatException::class.java) { EpicDownloadManager.blobToNum("0x1") } + assertThrows(NumberFormatException::class.java) { EpicDownloadManager.blobToNum("-05") } + } + + @Test + fun jsonManifestUsesBlobs() { + val json = """{ + "ManifestFileVersion": "006000000000", + "LaunchExeString": "Game\\Binaries\\Game.exe", + "ChunkHashList": { "0000000100000002000000030000000F": "001002003004005006007008" }, + "DataGroupList": { "0000000100000002000000030000000F": "007" }, + "ChunkFilesizeList": { "0000000100000002000000030000000F": "001002000000000000000000" }, + "FileManifestList": [ + { "Filename": "a.bin", + "FileChunkParts": [ { "Guid": "0000000100000002000000030000000F", "Offset": "001000000000", "Size": "000000016000" } ] } + ] + }""" + val m = EpicDownloadManager.parseManifest(json.toByteArray()) + assertNotNull(m) + assertEquals("ChunksV3", m.chunkDir) + assertEquals("Game/Binaries/Game.exe", m.launchExe) + val c = m.uniqueChunks.single() + assertEquals(0x0201L, c.fileSize) + assertEquals("ChunksV3/07/0807060504030201_0000000100000002000000030000000F.chunk", c.getPath(m.chunkDir)) + val part = m.files.single().parts.single() + assertEquals(1, part.offset) + assertEquals(1_048_576, part.size) + } +} From e4c375ad0d04b78d054828ec50c6dd19c38952e7 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:08:59 -0400 Subject: [PATCH 6/6] Fix Discord launcher layout and controller navigation (#596) --- .../com/droiddeck/launcher/MainActivity.kt | 4 + .../launcher/session/SessionPrefs.kt | 6 ++ .../droiddeck/launcher/ui/ComponentsPage.kt | 76 +++++++++---------- .../com/droiddeck/launcher/ui/DriverPage.kt | 16 +++- .../com/droiddeck/launcher/ui/FocusGlide.kt | 11 ++- .../com/droiddeck/launcher/ui/FrontEndRail.kt | 6 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 15 +++- .../droiddeck/launcher/ui/FrontEndSetup.kt | 1 + .../com/droiddeck/launcher/ui/LiftSheet.kt | 37 +++++++-- .../launcher/ui/ModeSettingsDialog.kt | 2 +- .../droiddeck/launcher/ui/SettingsWidgets.kt | 4 +- .../com/droiddeck/launcher/ui/SharedMotion.kt | 4 +- .../java/com/droiddeck/launcher/ui/StepOut.kt | 11 ++- .../launcher/ui/StoreGridNavigation.kt | 7 ++ .../droiddeck/launcher/ui/StoresDownloads.kt | 4 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 59 +++++++++++--- .../java/com/droiddeck/launcher/ui/Theme.kt | 2 +- .../droiddeck/launcher/ui/LiftSheetTest.kt | 6 +- .../launcher/ui/StoreGridNavigationTest.kt | 29 +++++++ docs/discord-ui-session-reports.md | 53 +++++++++++++ 20 files changed, 278 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt create mode 100644 docs/discord-ui-session-reports.md diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 3c3c6b541..6d3cf99b7 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -163,6 +163,7 @@ class MainActivity : ComponentActivity() { private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) private var launcherFullscreen by mutableStateOf(true) private var animationsEnabled by mutableStateOf(true) + private var gameStoresEnabled by mutableStateOf(true) private var gameStoresSpeedTier by mutableStateOf("fast") private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY) /** A page a notification asked for ("stores:downloads", "app:"), until the front end has gone there. */ @@ -534,6 +535,7 @@ class MainActivity : ComponentActivity() { requestedOrientation = orientation launcherFullscreen = SessionPrefs.launcherFullscreen(this) animationsEnabled = SessionPrefs.animationsEnabled(this) + gameStoresEnabled = SessionPrefs.gameStoresEnabled(this) gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this) storesOpenTab = SessionPrefs.storesOpenTab(this) storesShowMature = SessionPrefs.storesShowMature(this) @@ -587,6 +589,7 @@ class MainActivity : ComponentActivity() { orientation = orientation, launcherFullscreen = launcherFullscreen, animationsEnabled = animationsEnabled, + gameStoresEnabled = gameStoresEnabled, gameStoresSpeedTier = gameStoresSpeedTier, storesOpenTab = storesOpenTab, navigate = navRequest, @@ -727,6 +730,7 @@ class MainActivity : ComponentActivity() { offline = OfflineMode.enabled(this) }, onPageBack = { showPerformance = false; showComponents = false; showMapping = false }, + onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onLanguage = { tag -> if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 1596c2fe5..828a6c7c8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -71,6 +71,12 @@ object SessionPrefs { prefs(context).edit().putBoolean("animations", on).apply() } + fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", true) + + fun setGameStoresEnabled(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("gameStoresEnabled", on).apply() + } + /** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */ fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 7c5b6c9ad..383b03e1f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -739,7 +739,15 @@ fun ComponentsPage( Column(Modifier.fillMaxSize()) { // The in-use bar keeps its place; only its words change. AnimatedContent(l, transitionSpec = { fadeIn(Motion.tw(240)) togetherWith fadeOut(Motion.tw(160)) }, label = "inUse") { k -> bar(k) } - Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 16.dp)) { + val detailScroll = androidx.compose.runtime.key(l) { rememberScrollState() } + Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(detailScroll).padding(top = 12.dp, bottom = 16.dp)) { + // Shared performance settings stay visible before the downloadable catalog. + if (l == PROTON_TAB) { + if (!protons.runtimeReady) EmptyLine(stringResource(R.string.content_needs_runtime)) + if (protons.sessionRunning) EmptyLine(stringResource(R.string.proton_stop_first)) + SettingsGroup(stringResource(R.string.comp_for_every_proton)) { protonOptions() } + Spacer(Modifier.height(12.dp)) + } val slide: @Composable (@Composable (String) -> Unit) -> Unit = { body -> AnimatedContent( l, @@ -777,10 +785,6 @@ fun ComponentsPage( } } if (l == PROTON_TAB) { - Spacer(Modifier.height(10.dp)) - if (!protons.runtimeReady) EmptyLine(stringResource(R.string.content_needs_runtime)) - if (protons.sessionRunning) EmptyLine(stringResource(R.string.proton_stop_first)) - SettingsGroup(stringResource(R.string.comp_for_every_proton)) { protonOptions() } Spacer(Modifier.height(12.dp)) Note(stringResource(R.string.comp_about_text)) } @@ -815,41 +819,37 @@ fun ComponentsPage( if (narrow) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 6.dp)) { DeviceChip(gpu) } Spacer(Modifier.height(14.dp)) if (games.isNotEmpty()) { - SettingsGroup(stringResource(R.string.comp_profile_configure)) { - SettingsRow( - stringResource(R.string.comp_profile), - if (selectedGame == null) { - stringResource(R.string.comp_scope_general_detail) - } else { - stringResource(R.string.comp_profile_game_detail, selectedGame.name) - }, - ) { - Box { - ValueChip( - selectedGame?.name ?: stringResource(R.string.comp_scope_general), - scopeMenu, - modifier = Modifier.widthIn(max = 320.dp).heightIn(min = 44.dp), - ) { scopeMenu = !scopeMenu } - AnchoredMenu( - scopeMenu, - onDismiss = { scopeMenu = false }, - title = stringResource(R.string.comp_profile_configure), - ) { first -> - MenuItem( - stringResource(R.string.comp_scope_general), - checked = selectedGame == null, - detail = stringResource(R.string.comp_scope_general_detail), - focusRequester = first, - ) { - selectedGameKey = null + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) { + Text(stringResource(R.string.comp_profile), fontSize = 14.sp, color = colors.onSurfaceVariant) + Box(Modifier.weight(1f, fill = false)) { + ValueChip( + selectedGame?.name ?: stringResource(R.string.comp_scope_general), + scopeMenu, + modifier = Modifier.widthIn(max = 320.dp).heightIn(min = 44.dp), + ) { scopeMenu = !scopeMenu } + AnchoredMenu( + scopeMenu, + onDismiss = { scopeMenu = false }, + title = stringResource(R.string.comp_profile_configure), + ) { first -> + MenuItem( + stringResource(R.string.comp_scope_general), + checked = selectedGame == null, + detail = stringResource(R.string.comp_scope_general_detail), + focusRequester = first, + ) { + selectedGameKey = null + scopeMenu = false + } + games.sortedBy { it.name.lowercase() }.forEach { game -> + MenuItem(game.name, checked = selectedGameKey == game.profileKey) { + selectedGameKey = game.profileKey scopeMenu = false } - games.sortedBy { it.name.lowercase() }.forEach { game -> - MenuItem(game.name, checked = selectedGameKey == game.profileKey) { - selectedGameKey = game.profileKey - scopeMenu = false - } - } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index 8da760954..1e62c42e3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher.ui import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import androidx.activity.compose.BackHandler +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.background import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -42,6 +43,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -84,11 +90,19 @@ fun DriverPage( // D-pad: up from the first driver lands on refresh; the page opens with the driver in use focused. val refreshFocus = remember { FocusRequester() } val selectedFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } } + val inputMode = LocalInputModeManager.current + var pageFocused by remember { mutableStateOf(false) } + LaunchedEffect(inputMode.inputMode) { + if (inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect + // The chooser holds controller focus until its closing animation finishes. + snapshotFlow { Steps.step }.first { it == null } + focusWithinFrames({ pageFocused }) { selectedFocus } + } val refreshSrc = remember { MutableInteractionSource() } val refreshHot = refreshSrc.collectIsFocusedAsState().value || refreshSrc.collectIsHoveredAsState().value SettingsPage( host, title = title, onBack = onBack, lede = "$hint\n$status", + modifier = Modifier.onFocusChanged { pageFocused = it.hasFocus }.focusGroup(), action = { Box( contentAlignment = Alignment.Center, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt index ac8ba1085..8d0d9629e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -175,7 +175,7 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable val alpha = remember { Animatable(0f) } val scope = rememberCoroutineScope() // An edge each, the corner, and a droplet carrying all of them. - val jobs = remember { arrayOfNulls(6) } + val jobs = remember { arrayOfNulls(7) } val cur = glide.current var lastColor by remember { mutableStateOf(Color.Transparent) } if (cur != null) lastColor = cur.color @@ -191,8 +191,11 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable var lastMove = 0L fun go(i: Int, v: Float, spec: AnimationSpec, wait: Long) { // A drop cut short by another move turns back into an outline on the way. - if (jobs[5]?.isActive == true || solid.targetValue > 0f) scope.launch { solid.animateTo(0f, Motion.tw(120)) } - jobs[5]?.cancel() + if (jobs[5]?.isActive == true || solid.targetValue > 0f) { + jobs[5]?.cancel() + jobs[6]?.cancel() + jobs[6] = scope.launch { solid.animateTo(0f, Motion.tw(120)) } + } // Picks up the speed it is going at: restarting from rest on every step of a held // direction is what made it stutter. val speed = edges[i].velocity @@ -270,6 +273,8 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable // Focus passing between two controls can let go of one a moment before the next // takes it: wait a beat, so that reads as a move and not a fade out and back in. delay(60) + jobs.forEach { it?.cancel() } + solid.snapTo(0f) scope.launch { alpha.animateTo(0f, Motion.tw(140)) } shown = null return@collectLatest diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index 16290a1ec..c95ae71ae 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -106,14 +106,14 @@ internal fun SideRail( modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp), ) { // Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls. - val count = 7 + (if (s.isHomeApp) 1 else 0) + val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0) BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp) // A hop in flight moves the selection ahead of the page it is bringing. val shown = Hops.railPreview ?: selected val keys = buildList { add("steam"); add("games") - add("stores") + if (s.gameStoresEnabled) add("stores") add("desktop") add("components") if (s.isHomeApp) add("android-apps") @@ -145,7 +145,7 @@ internal fun SideRail( // The game stores, right under Games: what they install lands there. Its badge counts // the downloads in flight, so a download started here is never out of sight. // A section switched on in Setup comes in as the rail's own entrance; off, it folds away. - RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") } + if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") } RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", shown == "desktop", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", shown == "components", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("components") }) { onSelect("components") } // Home mode's extra section goes last, so it shifts nothing above it. diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index c148362eb..5845f8549 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -134,6 +134,7 @@ class FrontEndState( val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, val launcherFullscreen: Boolean = true, val animationsEnabled: Boolean = true, + val gameStoresEnabled: Boolean = true, /** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */ val gameStoresSpeedTier: String = "fast", /** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */ @@ -212,6 +213,7 @@ class FrontEndActions( val onOrientation: (Int) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, val onAnimationsEnabled: (Boolean) -> Unit = {}, + val onGameStoresEnabled: (Boolean) -> Unit = {}, val onGameStoresSpeedTier: (String) -> Unit = {}, val onStoresOpenTab: (String) -> Unit = {}, val onNavigated: () -> Unit = {}, @@ -339,6 +341,8 @@ internal class FrontFocus { /** Down from this button goes to the first tile of the page's grid, when there is one. */ @Composable internal fun Modifier.downToFirstTile(): Modifier { + // Sheet rows walk their settings; Desktop's dock stays composed behind the sheet. + if (LocalSheetBeat.current != null) return this val ff = LocalFrontFocus.current ?: return this return this.focusProperties { down = if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default } } @@ -499,6 +503,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } val inputModeManager = LocalInputModeManager.current val window = LocalWindowInfo.current + val androidContext = androidx.compose.ui.platform.LocalContext.current var anyFocused by remember { mutableStateOf(false) } Box( modifier = Modifier.fillMaxSize().background(colors.background) @@ -530,7 +535,15 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com if (processSettingsPageVisible) return@LaunchedEffect // Past the old page's exit (170ms), then the first frame the new page takes focus. kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) - if (anyFocused || inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect + if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect + if (s.pageKey == "performance") { + val back = "back:${androidContext.getString(R.string.mode_back)}" + focusWithinFrames({ frontFocus.last == back && anyFocused && frontFocus.focusedRail == null }) { + frontFocus.items[back] ?: FocusRequester.Default + } + return@LaunchedEffect + } + if (anyFocused) return@LaunchedEffect focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } } // A rail item picked with a controller moves on into its page, once the page is in. diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index fd6cfec3f..2302f319f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -366,6 +366,7 @@ internal fun SetupPanel( ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, s.animationsEnabled, onChange = a.onAnimationsEnabled) LanguageRow(host, s.language, a.onLanguage) + ToggleRow(host, "show-stores", stringResource(R.string.setup_stores), null, s.gameStoresEnabled, onChange = a.onGameStoresEnabled) } } val device: @Composable () -> Unit = { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt index 60667ee92..c038e5c52 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -37,10 +37,14 @@ 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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.ui.platform.testTag +import kotlinx.coroutines.Job import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -126,12 +130,12 @@ internal object SheetTab { /** * 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 + * riding it: 60dp 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) + maxOf(paneH * 60f / 360f, minAbove + buttonH / 2f - ride) /** A finger's hold on the sheet: where it started (0 rest, 1 open), where it is, whether it has it. */ private class SheetDrag { @@ -228,6 +232,7 @@ internal fun LiftHero( // Set as a finger's drag lets go: the sheet is already where it is going. var dragSettled by remember { mutableStateOf(false) } val scope = androidx.compose.runtime.rememberCoroutineScope() + var dragJob by remember { mutableStateOf(null) } val onOpenNow = rememberUpdatedState(onOpen) val pull = remember { Animatable(0f) } val sink = remember { Animatable(0f) } @@ -273,6 +278,7 @@ internal fun LiftHero( onDispose { lifecycle.removeObserver(obs) } } LaunchedEffect(open, launching) { + if (!dragSettled) { dragJob?.cancel(); dragJob = null } if (dragSettled) { // A finger brought it here and has already animated it: only what opening needs. dragSettled = false @@ -378,6 +384,8 @@ internal fun LiftHero( } /** A finger takes the sheet where it is: up from rest, or down from open. */ fun beginDrag() { + dragJob?.cancel(); dragJob = null + dragSettled = false drag.engaged = true drag.from = if (open) 1f else 0f drag.progress = drag.from @@ -401,7 +409,8 @@ internal fun LiftHero( else -> drag.progress > 0.3f } dragSettled = true - scope.launch { + dragJob?.cancel() + dragJob = scope.launch { val to = if (opens) 1f else 0f coroutineScope { launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) } @@ -554,7 +563,7 @@ internal fun LiftHero( .offset { IntOffset(chipsX.roundToInt(), chipsY.roundToInt()) } // Over the rows, whatever they scroll under them. .zIndex(1f) - .width(with(density) { (sheetW - chipsX - 16.dp.toPx()).coerceAtLeast(0f).toDp() }) + .width(with(density) { (sheetW - chipsX - 64.dp.toPx()).coerceAtLeast(0f).toDp() }) .graphicsLayer { val v = pieces[0].value; alpha = v; translationY = (1f - v) * 6.dp.toPx() } // Up from the chips folds the sheet (a finger drags it down, see the hero's drag). .onPreviewKeyEvent { e -> @@ -596,6 +605,18 @@ internal fun LiftHero( } } } + val closeSource = remember { MutableInteractionSource() } + val closeHot = rememberHot(closeSource) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset { IntOffset((sheetW - 52.dp.toPx()).roundToInt(), chipsY.roundToInt()) } + .zIndex(1f).size(44.dp).testTag("sheet-close") + .clip(CircleShape).glideBorder(closeHot, CircleShape, pal.signal) + .hoverable(closeSource) + .clickable(interactionSource = closeSource, indication = LocalIndication.current, enabled = open && !launching, role = Role.Button) { close() } + .controllerConfirm(enabled = open && !launching) { close() }, + ) { Icon(Icons.Filled.Close, stringResource(R.string.common_close), tint = colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) } } } @@ -643,15 +664,15 @@ internal fun LiftHero( // A lip a pad can reach stays composed (unseen at rest) so Down always has it to land on. if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f || (!downOpens && !open && !shown))) { var handleW by remember { mutableStateOf(0f) } - val lipH = 22.dp + pull.value.dp + val lipH = 32.dp + pull.value.dp val lift = { if (!open) open = true } Box( Modifier .offset { IntOffset((r0.center.x - handleW / 2f).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) } .onSizeChanged { handleW = it.width.toFloat() } .graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) } - // 44dp to touch; the tab itself is the top 22. - .heightIn(min = 44.dp) + // 44dp to touch, with a 32dp visible handle. + .heightIn(min = 44.dp).testTag("sheet-open") .semantics { contentDescription = settingsLabel } .then( if (downOpens) Modifier.focusProperties { canFocus = false } @@ -698,7 +719,7 @@ private fun Handle(height: Dp) { .drawBorderSidesAndFoot(pal.line, 12.dp) .padding(start = 14.dp, end = 10.dp), ) { - Text(stringResource(R.string.drawer_page_settings), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1) + Text(stringResource(R.string.drawer_page_settings), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1) Icon( Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else -3.dp.toPx() * lift }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 5aaf9f841..1f2561cbc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -474,7 +474,7 @@ internal fun ProtonOptions( onSyncBackend: (String) -> Unit, onFexPreset: (String) -> Unit, onForceSsbs: (Boolean) -> Unit, ) { val host = rememberMenuHost() - SettingsRow(stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint)) { + SettingsRow(stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint), stacked = true) { SegmentedTabs( listOf( SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index c94818f0d..9faabe7b6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -443,7 +443,7 @@ fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable * under it stay put. */ @Composable -fun SettingsRow(label: String, hint: String?, highlighted: Boolean = false, hintLines: Int? = null, control: @Composable () -> Unit) { +fun SettingsRow(label: String, hint: String?, highlighted: Boolean = false, hintLines: Int? = null, stacked: Boolean = false, control: @Composable () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val bg by animateColorAsState(if (highlighted) pal.signal.copy(alpha = 0.10f) else Color.Transparent, Motion.tw(200), label = "rowBg") @@ -452,7 +452,7 @@ fun SettingsRow(label: String, hint: String?, highlighted: Boolean = false, hint minLines = hintLines ?: 1, maxLines = hintLines ?: Int.MAX_VALUE, overflow = TextOverflow.Ellipsis) } // A narrow page stacks the control under its label, so neither squeezes the other. - if (LocalNarrowPane.current) Column( + if (stacked || LocalNarrowPane.current) Column( modifier = Modifier.fillMaxWidth().background(bg).padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 12.dp), ) { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt b/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt index 4d01f20da..9cb233e1e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt @@ -67,7 +67,7 @@ internal fun BoxScope.ChipGlide( val crossEnd = remember { mutableStateOf(0f) } val was = remember { intArrayOf(-1) } val vertical = axis == GlideAxis.Vertical - LaunchedEffect(index, at) { + LaunchedEffect(index, at, Motion.scale) { val b = at ?: return@LaunchedEffect val s = if (vertical) b.top else b.left val e = if (vertical) b.bottom else b.right @@ -88,6 +88,8 @@ internal fun BoxScope.ChipGlide( l.join(); r.join() pinch.animateTo(0f, Motion.sp(0.6f, 500f)) } else { + // An adjacent move can interrupt a long hop while it is still pinched. + launch { pinch.animateTo(0f, Motion.sp(0.6f, 500f)) } val forward = index > from val (lead, trail) = if (forward) end to start else start to end val leadTo = if (forward) e else s diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt b/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt index 624176b51..6ebca4cdd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -133,7 +135,7 @@ internal enum class StepSide { } /** The choices' side of a [StepOut]: each piece fades in on its own beat, and [first] takes focus. */ -internal class StepOutScope(private val items: List>, val first: FocusRequester, val open: Boolean) { +internal class StepOutScope(private val items: List>, val first: FocusRequester, val open: Boolean, val maxHeight: Dp) { /** Piece [i] (0 first) of the box: it fades and rises in after the box has stepped out. */ fun Modifier.stepItem(i: Int): Modifier = graphicsLayer { val v = items[i.coerceIn(0, items.lastIndex)].value @@ -185,6 +187,9 @@ internal fun StepOut( BoxWithConstraints(Modifier.fillMaxSize()) { val hostWidth = constraints.maxWidth.toFloat() val hostHeight = constraints.maxHeight.toFloat() + val availableHeight = with(density) { + maxOf(hostHeight - pill.bottom - pull, pill.top - pull).coerceAtLeast(44.dp.toPx()).toDp() + } // No room under the control (a row near the foot of the screen): it steps out upward // instead, the same outline mirrored about the control. val up = measured?.let { pill.bottom + pull + it.height > hostHeight && pill.top - pull - it.height >= 0f } ?: false @@ -265,13 +270,15 @@ internal fun StepOut( modifier = Modifier .offset { IntOffset(x.roundToInt(), (if (up) pill.top - pull - (measured?.height ?: 0) else pill.bottom + pull).roundToInt()) } .widthIn(min = with(density) { pill.width.toDp() }, max = with(density) { maxW.toDp() }) + .heightIn(max = availableHeight) .onSizeChanged { if (measured != it) measured = it } .controllerBack(onDismiss) // A pad stays in the box until it is answered or folded, as it did in Stop's dialog. .focusProperties { exit = { FocusRequester.Cancel } } .focusGroup() + .verticalScroll(rememberScrollState()) .padding(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 12.dp), - ) { StepOutScope(pieces, first, open).content() } + ) { StepOutScope(pieces, first, open, availableHeight).content() } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt new file mode 100644 index 000000000..5676929ba --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt @@ -0,0 +1,7 @@ +package com.droiddeck.launcher.ui + +/** Vertical controller moves keep the same column, including across lazy item boundaries. */ +internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int): Int? { + if (index !in 0 until count || columns < 1 || direction !in listOf(-1, 1)) return null + return (index + columns * direction).takeIf { it in 0 until count } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index 8f902df1c..3bea8e49a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -505,11 +505,11 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State + androidx.compose.animation.Crossfade(art, animationSpec = Motion.tw(600), label = "signInWall", modifier = Modifier.matchParentSize()) { tiles -> TiltedWall(tiles.size, if (tiles.isEmpty()) 75_000 else 50_000) { i -> if (tiles.isEmpty()) blank else WallCapsule(ground, tiles[i]) } } // The wall gives way to the words: dark at the left and the foot, and the store's glow under them. Box( - Modifier.fillMaxSize().background( + Modifier.matchParentSize().background( Brush.horizontalGradient( 0f to colors.background.copy(alpha = 0.97f), 0.45f to colors.background.copy(alpha = 0.9f), 0.78f to colors.background.copy(alpha = 0.35f), 1f to colors.background.copy(alpha = 0.15f), ), ), ) - Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.5f to Color.Transparent, 1f to colors.background.copy(alpha = 0.92f)))) + Box(Modifier.matchParentSize().background(Brush.verticalGradient(0.5f to Color.Transparent, 1f to colors.background.copy(alpha = 0.92f)))) Box( - Modifier.fillMaxSize().drawBehind { + Modifier.matchParentSize().drawBehind { drawRect(Brush.radialGradient(listOf(c.dot.copy(alpha = 0.22f), Color.Transparent), center = Offset(size.width * 0.16f, size.height * 0.9f), radius = size.height * 0.75f)) }, ) @@ -823,13 +827,50 @@ private fun Storefront( // A lazy item can be recomposed or prefetched out of order; the first grid card is identified // by its key, rather than whichever happens to compose first. var firstPlaced = false + var columns by remember { mutableIntStateOf(1) } + val density = androidx.compose.ui.platform.LocalDensity.current + val focus = LocalFrontFocus.current + val scope = rememberCoroutineScope() + val headerItems = 1 + (if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0) val card: @Composable (CatalogItem) -> Unit = { item -> val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced firstPlaced = true - GameCard(item, store, s, a, installedKeys, onOpen, first) + val navigation = if (!showGrid || focus == null) Modifier else Modifier.onPreviewKeyEvent { event -> + val key = event.nativeKeyEvent + val direction = when (key.keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> 1 + KeyEvent.KEYCODE_DPAD_UP -> -1 + else -> 0 + } + val index = visible.indexOfFirst { it.key == item.key } + val next = storeGridNeighbor(index, visible.size, columns, direction) + when { + direction == 0 || index < 0 -> false + next == null -> direction > 0 // The last row stays in its column; Up can reach the header. + else -> { + if (key.action == KeyEvent.ACTION_DOWN) { + val target = "card:${visible[next].key}" + val targetIndex = headerItems + next + // Prefetched cards can accept focus without being on screen. + val visibleTarget = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex } + if (!visibleTarget || (focus.attached[target] ?: 0) == 0 || + runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch { + grid.scrollToItem(targetIndex) + focusWithinFrames({ focus.last == target }) { focus.items[target] ?: androidx.compose.ui.focus.FocusRequester.Default } + } + } + true + } + } + } + GameCard(item, store, s, a, installedKeys, onOpen, first, navigation) } LazyVerticalGrid( - columns = GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize(), + columns = GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { + val gap = with(density) { 10.dp.roundToPx() } + val minimum = with(density) { CardWidth.roundToPx() } + columns = ((it.width + gap) / (minimum + gap)).toInt().coerceAtLeast(1) + }, horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), ) { @@ -1066,7 +1107,7 @@ private val CardWidth = 112.dp * on the store. Tapping the card itself opens the game's page. */ @Composable -private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit, first: Boolean = false) { +private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit, first: Boolean = false, navigation: Modifier = Modifier) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -1095,7 +1136,7 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front if (b.bottom > -ahead && b.top < screen + ahead && b.width > 0f) com.droiddeck.launcher.stores.StoreSizes.request(ctx, item) } } - .paneItem("card:${item.key}").then(if (first) Modifier.firstTile() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale } + .then(navigation).paneItem("card:${item.key}").then(if (first) Modifier.firstTile() else Modifier).graphicsLayer { scaleX = scale; scaleY = scale } .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index a7c62ac73..d8c44bae0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -42,7 +42,7 @@ object Themes { PAPER, R.string.theme_paper, R.string.theme_paper_detail, background = Color(0xFF000000), surface = Color(0xFF0F0F10), surfaceVariant = Color(0xFF191A1C), line = Color(0xFF262729), line2 = Color(0xFF343638), onBackground = Color(0xFFF9F9F9), onSurfaceVariant = Color(0xFF8E9196), - primary = Color(0xFFF9F9F9), primary2 = Color(0xFFD9DADD), onPrimary = Color(0xFF000000), signal = Color(0xFF136CE0), + primary = Color(0xFFF9F9F9), primary2 = Color(0xFFD9DADD), onPrimary = Color(0xFF000000), signal = Color(0xFFF9F9F9), good = Color(0xFF4CD37F), ), Palette( diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt index c5b8003b9..36c206f05 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt @@ -4,9 +4,9 @@ import org.junit.Assert.assertEquals import org.junit.Test class LiftSheetTest { - @Test fun theSheetTopIs82dpDownA360dpPane() { - assertEquals(82f, sheetTop(360f, 56f, 16f, 3f), 0.01f) - assertEquals(164f, sheetTop(720f, 56f, 16f, 3f), 0.01f) + @Test fun theSheetTopIs60dpDownA360dpPane() { + assertEquals(60f, sheetTop(360f, 56f, 16f, 3f), 0.01f) + assertEquals(120f, sheetTop(720f, 56f, 16f, 3f), 0.01f) } @Test fun theButtonKeeps16dpAboveIt() { diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt new file mode 100644 index 000000000..4f0b9d421 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt @@ -0,0 +1,29 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StoreGridNavigationTest { + @Test fun downAndUpPreserveEveryColumnAcrossSeveralRows() { + for (columns in 1..6) for (index in 0 until columns * 3) { + val down = storeGridNeighbor(index, columns * 4, columns, 1)!! + assertEquals(index % columns, down % columns) + assertEquals(index, storeGridNeighbor(down, columns * 4, columns, -1)) + } + } + + @Test fun incompleteLastRowDoesNotWrapBackToTheLeft() { + assertEquals(8, storeGridNeighbor(4, 10, 4, 1)) + assertEquals(9, storeGridNeighbor(5, 10, 4, 1)) + assertNull(storeGridNeighbor(6, 10, 4, 1)) + assertNull(storeGridNeighbor(7, 10, 4, 1)) + assertNull(storeGridNeighbor(9, 10, 4, 1)) + } + + @Test fun firstRowCanLeaveUpwardsAndChangedColumnCountsAreUsed() { + assertNull(storeGridNeighbor(2, 10, 4, -1)) + assertEquals(5, storeGridNeighbor(2, 10, 3, 1)) + assertEquals(6, storeGridNeighbor(2, 10, 4, 1)) + } +} diff --git a/docs/discord-ui-session-reports.md b/docs/discord-ui-session-reports.md new file mode 100644 index 000000000..d4f5c40a2 --- /dev/null +++ b/docs/discord-ui-session-reports.md @@ -0,0 +1,53 @@ +# Discord UI and session reports, 2026-10-11 + +Sources: [bug thread](https://discord.com/channels/1552707793247539220/1558928314956120174), [general](https://discord.com/channels/1552707793247539220/1552707794430459916), and [development](https://discord.com/channels/1552707793247539220/1552751182165049435). Reviewed the post-overhaul discussion on October 11, including screenshot-only messages and linked reports. + +Branch: `fix/discord-ui-session-reports`, based on `origin/main` at `0cafa83e`. +Device: AYN Thor, selected by the full advertised ADB serial. + +## Changes + +| Report | Change | +| --- | --- | +| Settings needs an X and a larger handle | Add a 44dp close target in the sheet's controller focus group; enlarge the visible handle to 32dp and its label to 14sp. | +| Settings has too little height | Raise the sheet from 82/360 to 60/360 of the pane while preserving clearance above the primary action. | +| Reopening during the closing animation breaks settings | Cancel the outstanding drag settlement when a new gesture or open/close transition takes ownership. | +| Store navigation returns to the left column | Route Up/Down by the actual adaptive column count and focus the corresponding card after lazy composition. Incomplete final rows do not wrap. Prefetched offscreen cards are scrolled into view before focus moves. | +| A selection stays as a dot | Unpinch an interrupted long chip jump during the next adjacent move. Cancel an obsolete focus-ring flight and its fill animation on focus loss. | +| Performance opens with focus on Steam | Wait for Back to attach and take focus after the outgoing page leaves. | +| Desktop settings navigation jumps | Do not apply the main page's Down-to-first-app shortcut to settings-sheet buttons. | +| Display-driver navigation loses focus | Wait for the chooser to disappear before focusing the selected driver; initialize controller focus when switching back from touch. | +| Stores can no longer be hidden | Restore a persisted Stores switch under Setup > Launcher; keep it enabled by default. | +| Stores engine is clipped in the settings panel | Bound popup height to the available screen space, expose that limit to its scrollable body, and allow tall popup content to scroll. | +| Wine Sync renders vertically | Stack its label above the full-width sync choices. | +| Profile picker leaves too little room for Proton lists | Replace the large profile group with a compact 44dp picker row. | +| Theme accent is inconsistent | Paper's signal color now follows its white primary color rather than retaining blue. Store brand colors and controller-button tint remain separate settings. | + +| Phone sign-in buttons clipped ([development report](https://discord.com/channels/1552707793247539220/1552751182165049435/1558947589997469820)) | Size the sign-in card to the pane and its content instead of enforcing a 320dp minimum. Decorative layers follow the measured card. | +| Force SSBS hard to find behind empty scrolling ([general report](https://discord.com/channels/1552707793247539220/1552707794430459916/1558976573758251029)) | Place shared Proton options before installed/downloadable lists; start each component layer's detail at the top when switching layers. | + +## Validation + +The release APK was prepared with `tools/build_local.sh`, built with `assembleRelease`, signed with the normal release lineage, and installed with `adb install -r` over Thor's existing app. + +16 focused unit tests passed: LiftSheetTest, StoreGridNavigationTest, GlideShapeTest, StoreTabsTest, AppScaleTest, and the Compose ComponentsPageProfileTest. The first unrestricted unit-test run ended because the Gradle test executor exited with code 2 without an individual test failure report; it is not counted as a passing full suite. + +Thor checks include the close control, eight close/reopen swipe cycles, 21 rapid long/adjacent Setup tab switches, hiding and restoring Stores, Paper theme colors, controller entry into Performance (Back focused, then Down to the first setting), driver chooser handoff and Up/Down scrolling, and Desktop Files navigation (Open > Choose > Uninstall > Choose > Open). The new close control is reachable and operable with a controller. The newer Stores engine clipping, compact profile picker, and horizontal Wine Sync label were also checked on Thor. The original Graphite theme and existing session preferences were restored after checks. + +Device screenshots and UI dumps are saved locally under `captures/discord-reports/` (ignored build evidence). The final signed APK is `app/build/outputs/apk/release/app-release-signed.apk`. + +The first pass saw no populated authenticated store grid. During the follow-up, Epic and Amazon loaded their retained accounts and libraries (402 and 150 titles). On Epic, six controller Down moves and six Up moves stayed in the third column, including transitions to offscreen rows, and returned to the original card. This caught and fixed the case where a prefetched offscreen card accepted focus without scrolling. The completed trace is `captures/discord-reports/epic-grid-navigation.json`. + +Follow-up validation used both Thor's normal display and a temporary 2400x1080 / 540dpi phone-like layout. GOG's sign-in card and button were fully visible without scrolling. Epic and Amazon remained signed in, so their library layouts were inspected rather than signing the accounts out. Proton options appeared before the catalogs, Force SSBS was reachable by scrolling, and changing layers reset the detail scroll. The physical size/density were restored (1080x1920 / 369dpi). The final build and four focused tests passed; the final signed APK hash is `7dca710d081042ec68aeaf6e8b68371d5fdfa73eca5860ba4ca5f0134c21b9fc`. Wireless ADB file-sync stalled, so the APK was transferred over a temporary direct LAN socket, hash-verified on Thor, and installed with `pm install -r`. The transfer listener closed after delivery. + +## Runtime reports still unresolved + +**Second Steam launch fails or has sound with a black picture.** The two submitted log bundles reach a first frame and Steam focus, then record a guest exit with status 0. Their crash buffers contain no Android crash. Two consecutive launches of Thor's existing main build reached READY and displayed Steam correctly, using the same retained compositor and app process. Two consecutive Steam launches of the new branch also reached READY with visible Steam UI, and its desktop launch reached READY and stopped cleanly. These observations do not establish the cause of the reporters' failures, and no speculative compositor or Steam-startup change was made. + +**Persona 3 Reload drops from 60 to 35 FPS and crashes.** The linked compatibility thread identifies an Odin 3 Max and a UE4 GameThread timeout waiting 120 seconds for RenderThread. Earlier working settings were Performance + TSO and VKD3D shader/feature levels 6_6 / 12_1. The new report has no session log, before/after build identifiers, or controlled comparison. Persona 3 Reload was not found in the inspected Thor library locations. Neither an Odin reproduction nor a Persona performance improvement is claimed. The RDR2 launcher governor matches Rockstar helper executable paths and is not a blanket game-core restriction. + +## Related follow-ups outside launcher design + +General's fullscreen desktop-app report matches the existing runtime contract: the software desktop hands Steam to a Steam session and wraps games/emulators in gamescope for GPU access. `droiddeck-desktop-gpu` preserves a “Run in a desktop window” action. Changing that GPU/window architecture is separate runtime work; no claim of fixing it here. + +Development's request to update driver and Proton defaults without visiting Components is also runtime policy. Driver Auto already calls `ensureAuto` during activity startup. This branch does not replace manual driver selections or install/switch Proton builds as part of a layout fix.