diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 68b652ed0..8d179a499 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -431,6 +431,9 @@ jobs: - name: Unit tests run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION" + - name: Render Compose previews + run: ./gradlew testDebugUnitTest --tests '*PreviewRenderTest' --console=plain -PndkVersion="$NDK_VERSION" + - name: Check the native dependency closure run: | set -euo pipefail diff --git a/app/build.gradle b/app/build.gradle index 9e9765e88..7dafc08bc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -198,6 +198,10 @@ dependencies { // on-screen pad are Views underneath it, which is what they are in every Compose app. implementation platform('androidx.compose:compose-bom:2024.02.00') implementation 'androidx.compose.ui:ui' + debugImplementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + testImplementation 'androidx.compose.ui:ui-test-junit4' implementation 'androidx.compose.material3:material3' implementation 'androidx.activity:activity-compose:1.8.2' // The File Manager, Bannerlator's: its icon set, image thumbnails and the coroutines it diff --git a/app/src/debug/java/com/droiddeck/launcher/files/FilePreviews.kt b/app/src/debug/java/com/droiddeck/launcher/files/FilePreviews.kt new file mode 100644 index 000000000..057310e17 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/files/FilePreviews.kt @@ -0,0 +1,87 @@ +package com.droiddeck.launcher.files + +import androidx.compose.runtime.Composable +import com.droiddeck.launcher.ui.DroidDeckPreviews +import com.droiddeck.launcher.ui.PreviewCase +import com.droiddeck.launcher.ui.PreviewHost +import java.io.File + +/** File metadata only: no files are created and no real directory is enumerated. */ +private class SampleFile(path: String, private val directory: Boolean = false, private val bytes: Long = 0) : File(path) { + override fun isDirectory() = directory + override fun isFile() = !directory + override fun exists() = true + override fun canRead() = true + override fun canWrite() = false + override fun length() = bytes + override fun getUsableSpace() = 32_000_000_000L + override fun getFreeSpace() = 32_000_000_000L + override fun lastModified() = 1_791_331_200_000L + override fun getCanonicalPath() = absolutePath + override fun getCanonicalFile() = this + override fun listFiles(): Array = emptyArray() +} + +private val directory = SampleFile("/storage/emulated/0", directory = true) +private val entries = listOf( + SampleFile("/storage/emulated/0/Games", directory = true), + SampleFile("/storage/emulated/0/ROMs", directory = true), + SampleFile("/storage/emulated/0/Downloads", directory = true), + SampleFile("/storage/emulated/0/Portal 2.exe", bytes = 4_200_000), + SampleFile("/storage/emulated/0/session.log", bytes = 32_000), + SampleFile("/storage/emulated/0/save.zip", bytes = 12_000_000), +) + +@DroidDeckPreviews +@Composable +internal fun FileGridPreview() = PreviewHost { + FileManagerScreen(preview = FileManagerPreviewState(directory, entries)) +} + +@DroidDeckPreviews +@Composable +internal fun FileListPreview() = PreviewHost { + FileManagerScreen(preview = FileManagerPreviewState(directory, entries, grid = false)) +} + +@DroidDeckPreviews +@Composable +internal fun FileEmptyPreview() = PreviewHost { + FileManagerScreen(preview = FileManagerPreviewState(directory, emptyList())) +} + +@DroidDeckPreviews +@Composable +internal fun FilePickerPreview() = PreviewHost { + FileManagerScreen(pickMode = true, pickExtensions = listOf("exe"), pickerTitle = "Choose executable", onPick = {}, + preview = FileManagerPreviewState(directory, entries.filter { it.isDirectory || it.extension == "exe" })) +} + +@DroidDeckPreviews +@Composable +internal fun FolderPickerPreview() = PreviewHost { + FileManagerScreen(pickMode = true, pickDirMode = true, onPick = {}, + preview = FileManagerPreviewState(directory, entries.filter { it.isDirectory })) +} + +@DroidDeckPreviews +@Composable +internal fun FilePropertiesPreview() = PreviewHost { + FilePropertiesDialog(entries.last(), {}, {}) +} + +@DroidDeckPreviews +@Composable +internal fun FavoritesPreview() = PreviewHost { + FileManagerScreen(preview = FileManagerPreviewState(directory, entries, favorites = true)) +} + +internal val filePreviewCases = listOf( + PreviewCase("Favorites") { FavoritesPreview() }, + PreviewCase("FileGrid") { FileGridPreview() }, + PreviewCase("FileList") { FileListPreview() }, + PreviewCase("FileEmpty") { FileEmptyPreview() }, + PreviewCase("FilePicker") { FilePickerPreview() }, + PreviewCase("FolderPicker") { FolderPickerPreview() }, + PreviewCase("FileProperties") { FilePropertiesPreview() }, +) diff --git a/app/src/debug/java/com/droiddeck/launcher/ui/DialogPreviews.kt b/app/src/debug/java/com/droiddeck/launcher/ui/DialogPreviews.kt new file mode 100644 index 000000000..dd4d216c1 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/ui/DialogPreviews.kt @@ -0,0 +1,93 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable +import com.droiddeck.launcher.core.PhantomProcessStatus +import com.droiddeck.launcher.runtime.UserApps + +@DroidDeckPreviews +@Composable +internal fun WirelessAdbPreview() { + PreviewHost { + WirelessAdbFixPage({}, true, {}, { _, _, _, done -> done("Preview only") }, { _, done -> done(null) }, { _, _, _, done -> done("Preview only") }) + } +} + +@DroidDeckPreviews +@Composable +internal fun ProcessLimitPreview() { + PreviewHost { + PhantomProcessGatePage(PhantomProcessStatus.ENABLED, {}, {}, {}, {}, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun AddAppPreview() { + PreviewHost { + AddAppDialog(true, {}) { _, _ -> } + } +} + +@DroidDeckPreviews +@Composable +internal fun EditAppPreview() { + PreviewHost { + EditAppDialog(UserApps.App("sample", UserApps.Kind.APPIMAGE, "Example app", null, "AppImage", "/preview/example.AppImage", emptyList()), {}) { _, _, _ -> } + } +} + +@DroidDeckPreviews +@Composable +internal fun ConfirmationPreview() { + PreviewHost { + ConfirmDialog("Remove runtime?", "Remove the installed Linux runtime.", "Remove", {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun CustomResolutionPreview() { + PreviewHost { + CustomResolutionDialog(1280 to 720, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun RomsFolderPreview() { + PreviewHost { + RomsDialog("/preview/ROMs", {}, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun GameEnvironmentPreview() { + PreviewHost { + GameEnvironmentEditor(false, com.droiddeck.launcher.core.GameEnvironment.Config(shared = mapOf("DXVK_HUD" to "fps", "MANGOHUD" to "1"))) {} + } +} + +@DroidDeckPreviews +@Composable +internal fun WindowsComponentsPreview() { + PreviewHost { + WinComponentsDialog("100", "Portal 2", null, false, onClose = {}, previewComponents = listOf( + com.droiddeck.launcher.session.WinComponents.Component("vcrun2022", "Visual C++ runtime", "Microsoft", "ready", emptyList(), emptyList()), + com.droiddeck.launcher.session.WinComponents.Component("d3dcompiler_47", "DirectX shader compiler", "Microsoft", "ready", emptyList(), emptyList()), + )) + } +} + +@DroidDeckPreviews +@Composable +internal fun DeveloperDisplayPreview() = PreviewHost { + DeveloperDisplayChoiceDialog(listOf(2 to "Secondary screen"), {}, {}, {}) +} + +@DroidDeckPreviews +@Composable +internal fun AppDisplayPreview() = PreviewHost { + ChooseAppDisplayDialog(com.droiddeck.launcher.HomeApp.LaunchableApp("Files", "preview.files", "preview.files.Main", null), + com.droiddeck.launcher.input.SecondScreenDisplay(2, "Secondary screen"), {}, {}, {}) +} diff --git a/app/src/debug/java/com/droiddeck/launcher/ui/LauncherPreviews.kt b/app/src/debug/java/com/droiddeck/launcher/ui/LauncherPreviews.kt new file mode 100644 index 000000000..de543b8f8 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/ui/LauncherPreviews.kt @@ -0,0 +1,129 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@DroidDeckPreviews +@Composable +internal fun LauncherPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(), PreviewData.frontActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun RuntimeMissingPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(ready = false), PreviewData.frontActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun RuntimeDownloadingPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(ready = false, busy = true), PreviewData.frontActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun LibraryPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "games") + } +} + +@DroidDeckPreviews +@Composable +internal fun EmptyLibraryPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(empty = true), PreviewData.frontActions, initialSelection = "games") + } +} + +@DroidDeckPreviews +@Composable +internal fun GameDetailsPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "app:100") + } +} + +@DroidDeckPreviews +@Composable +internal fun DesktopPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "desktop") + } +} + +@DroidDeckPreviews +@Composable +internal fun EmulatorPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "emu:dolphin") + } +} + +@DroidDeckPreviews +@Composable +internal fun ArtPreview() { + PreviewHost { + Column(Modifier.fillMaxSize().padding(24.dp)) { + ArtGrid(PreviewData.games.map { Tile(it.name, "Installed", null, it.gameIdString, onClick = {}) }) + } + } +} + +@DroidDeckPreviews +@Composable +internal fun UpdatesPreview() { + PreviewHost { + UpdatesPage(PreviewData.front(updates = UpdatesState(catalog = PreviewData.updatesCatalog)), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed) + } +} + +@DroidDeckPreviews +@Composable +internal fun UpdatesDownloadingPreview() { + PreviewHost { + UpdatesPage(PreviewData.front(updates = UpdatesState(catalog = PreviewData.updatesCatalog, stage = "Downloading", percent = 42)), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed) + } +} + +@DroidDeckPreviews +@Composable +internal fun UpdatesErrorPreview() { + PreviewHost { + UpdatesPage(PreviewData.front(updates = UpdatesState(error = "Could not check for updates")), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed) + } +} + +@Preview(name = "Paper theme", widthDp = 960, heightDp = 540, apiLevel = 34) +@Composable +internal fun AlternateThemePreview() { + PreviewHost(theme = Themes.PAPER) { FrontEndScreen(PreviewData.front(), PreviewData.frontActions) } +} + +@DroidDeckPreviews +@Composable +internal fun StorePreview() = PreviewHost { + val apps = listOf( + com.droiddeck.launcher.store.FlathubApi.AppSummary("org.videolan.VLC", "VLC", "Media player", null, "VideoLAN", true, 100_000), + com.droiddeck.launcher.store.FlathubApi.AppSummary("org.mozilla.firefox", "Firefox", "Web browser", null, "Mozilla", true, 100_000), + ) + StorePage(PreviewData.front(), PreviewData.frontActions, Modifier.fillMaxSize(), previewSections = mapOf("popular" to apps, "trending" to apps)) +} + +@DroidDeckPreviews +@Composable +internal fun AndroidAppsPreview() = PreviewHost { + FrontEndScreen(PreviewData.front(home = true), PreviewData.frontActions, initialSelection = "android-apps") +} diff --git a/app/src/debug/java/com/droiddeck/launcher/ui/PreviewCases.kt b/app/src/debug/java/com/droiddeck/launcher/ui/PreviewCases.kt new file mode 100644 index 000000000..be5e9ac34 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/ui/PreviewCases.kt @@ -0,0 +1,71 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable + +internal class PreviewCase(val name: String, val content: @Composable () -> Unit) { + override fun toString() = name +} + +internal val screenPreviewCases = listOf( + PreviewCase("AlternateTheme") { AlternateThemePreview() }, + PreviewCase("Launcher") { LauncherPreview() }, + PreviewCase("RuntimeMissing") { RuntimeMissingPreview() }, + PreviewCase("RuntimeDownloading") { RuntimeDownloadingPreview() }, + PreviewCase("Library") { LibraryPreview() }, + PreviewCase("EmptyLibrary") { EmptyLibraryPreview() }, + PreviewCase("GameDetails") { GameDetailsPreview() }, + PreviewCase("Desktop") { DesktopPreview() }, + PreviewCase("Emulator") { EmulatorPreview() }, + PreviewCase("Setup") { SetupPreview() }, + PreviewCase("ControllerMapping") { ControllerMappingPreview() }, + PreviewCase("GpuDrivers") { GpuDriversPreview() }, + PreviewCase("Protons") { ProtonsPreview() }, + PreviewCase("ProtonDownload") { ProtonDownloadPreview() }, + PreviewCase("ProtonsEmpty") { ProtonsEmptyPreview() }, + PreviewCase("Drivers") { DriversPreview() }, + PreviewCase("Loading") { LoadingPreview() }, + PreviewCase("SessionStarting") { SessionStartingPreview() }, + PreviewCase("SessionError") { SessionErrorPreview() }, + PreviewCase("Keyboard") { KeyboardPreview() }, + PreviewCase("Hud") { HudPreview() }, + PreviewCase("WirelessAdb") { WirelessAdbPreview() }, + PreviewCase("ProcessLimit") { ProcessLimitPreview() }, + PreviewCase("AddApp") { AddAppPreview() }, + PreviewCase("EditApp") { EditAppPreview() }, + PreviewCase("Confirmation") { ConfirmationPreview() }, + PreviewCase("CustomResolution") { CustomResolutionPreview() }, + PreviewCase("RomsFolder") { RomsFolderPreview() }, + PreviewCase("GameEnvironment") { GameEnvironmentPreview() }, + PreviewCase("WindowsComponents") { WindowsComponentsPreview() }, + PreviewCase("Controls") { ControlsPreview() }, + PreviewCase("Widgets") { WidgetsPreview() }, + PreviewCase("Art") { ArtPreview() }, + PreviewCase("Cursor") { CursorPreview() }, + PreviewCase("SteamDisplay") { SteamDisplayPreview() }, + PreviewCase("SteamControls") { SteamControlsPreview() }, + PreviewCase("SteamGames") { SteamGamesPreview() }, + PreviewCase("SteamAudio") { SteamAudioPreview() }, + PreviewCase("SteamSession") { SteamSessionPreview() }, + PreviewCase("SteamSteam") { SteamSteamPreview() }, + PreviewCase("DesktopSettings") { DesktopSettingsPreview() }, + PreviewCase("DrawerController") { DrawerControllerPreview() }, + PreviewCase("DrawerSecondScreen") { DrawerSecondScreenPreview() }, + PreviewCase("DrawerDisplay") { DrawerDisplayPreview() }, + PreviewCase("DrawerEffects") { DrawerEffectsPreview() }, + PreviewCase("DrawerGames") { DrawerGamesPreview() }, + PreviewCase("DrawerSession") { DrawerSessionPreview() }, + PreviewCase("Performance") { PerformancePreview() }, + PreviewCase("Components") { ComponentsPreview() }, + PreviewCase("ComponentsLoading") { ComponentsLoadingPreview() }, + PreviewCase("GpuComponents") { GpuComponentsPreview() }, + PreviewCase("Updates") { UpdatesPreview() }, + PreviewCase("UpdatesDownloading") { UpdatesDownloadingPreview() }, + PreviewCase("UpdatesError") { UpdatesErrorPreview() }, + PreviewCase("Store") { StorePreview() }, + PreviewCase("DeveloperDisplay") { DeveloperDisplayPreview() }, + PreviewCase("AppDisplay") { AppDisplayPreview() }, + PreviewCase("AndroidApps") { AndroidAppsPreview() }, + PreviewCase("SetupController") { SetupControllerPreview() }, + PreviewCase("SetupSession") { SetupSessionPreview() }, + PreviewCase("SetupLauncher") { SetupLauncherPreview() }, +) diff --git a/app/src/debug/java/com/droiddeck/launcher/ui/PreviewData.kt b/app/src/debug/java/com/droiddeck/launcher/ui/PreviewData.kt new file mode 100644 index 000000000..1b247b4f1 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/ui/PreviewData.kt @@ -0,0 +1,143 @@ +package com.droiddeck.launcher.ui + +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless +import com.droiddeck.launcher.input.ControllerPrefs +import com.droiddeck.launcher.input.SecondScreenMode +import com.droiddeck.launcher.input.SecondScreenDisplay +import com.droiddeck.launcher.session.ComponentsManager +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.update.AppUpdates +import java.io.File + +/** Deterministic fixtures shared by Studio previews and the render smoke tests. */ +internal object PreviewData { + val games = listOf("Portal 2", "Celeste", "Stardew Valley", "Hollow Knight", "A Short Hike", "Dead Cells") + .mapIndexed { i, name -> Library.SteamGame(100 + i, name, null, "/preview/steam") } + val controller = ControllerPrefs.Settings(0xFF1A9FFF.toInt(), 80, 100, true, true, true, true, true, true, false, emptyMap()) + fun front(empty: Boolean = false, busy: Boolean = false, ready: Boolean = true, home: Boolean = false, updates: UpdatesState = UpdatesState()) = FrontEndState( + installed = if (ready) "2026.10.07" else null, ready = ready, available = "2026.10.07", + busy = busy, stage = if (busy) "Downloading runtime" else "", percent = if (busy) 42 else -1, + desktopInstalled = ready, offlineAccount = "Player", offline = false, frameGenLabel = "Off", romsDir = "/preview/ROMs", logsEnabled = true, + steamGames = if (empty) emptyList() else games, + emulators = listOf(Library.Emulator("dolphin", "Dolphin", "GameCube / Wii", "dolphin-emu", true, listOf( + Library.Rom("Wind Waker", File("/preview/ROMs/Wind Waker.iso"), "/root/ROMs/Wind Waker.iso", "dolphin"), + ))), running = null, controller = controller, animationsEnabled = false, storeEnabled = true, + buildLabel = "Design preview", updates = updates, isHomeApp = home, homeScreenEnabled = home, + androidApps = if (home) listOf(com.droiddeck.launcher.HomeApp.LaunchableApp("Files", "preview.files", "preview.files.Main", null), + com.droiddeck.launcher.HomeApp.LaunchableApp("Settings", "preview.settings", "preview.settings.Main", null)) else emptyList(), + ) + val frontActions = FrontEndActions( + onPlay = {}, + onPlayDesktopUi = {}, + onSteamGame = {}, + onDesktop = {}, + onEmulator = {}, + onRom = {}, + onResume = {}, + onSteamSettings = {}, + onDesktopSettings = {}, + onInstallPackage = {}, + onRemovePackage = {}, + onRuntime = {}, + onFrameGenPick = {}, + onImportLossless = {}, + onProtons = {}, + onComponents = {}, + onPerformance = {}, + onRoms = {}, + onFiles = {}, + onLogs = {}, + onOffline = {}, + onWirelessAdbPair = { _, _, _, done -> done("Preview only") }, + onWirelessAdbApply = { _, _, _, done -> done("Preview only") }, + onSetPhantomProcessLimit = { _, done -> done("Preview only") }, + controller = ControllerActions( + onOsc = {}, + onTint = {}, + onOpacity = {}, + onSize = {}, + onStickClick = {}, + onAdaptiveSticks = {}, + onRumble = {}, + onSteamButton = {}, + onQamButton = {}, + onKeyboardButton = {}, + onEditLayout = {}, + onResetLayout = {}, + onMapping = {}, + onResetAll = {}, + ), + ) + fun mode(steam: Boolean = true) = ModeSettings( + mode = if (steam) SessionService.MODE_STEAM else SessionService.MODE_DESKTOP, resolution = "1280x720", + hdr = false, hdrReason = "HDR is unavailable on this display", touchMode = SessionPrefs.TOUCH_AUTO, + suspendPolicy = SessionPrefs.SUSPEND_AUTO, oscMode = SessionPrefs.OSC_AUTO, + directAudio = true, mic = false, renderer = "vulkan", stretch16x9 = false, + pipSupported = true, syncBackend = "auto", steamChannel = "stable", steamController = "deck", + addedGamesDirs = listOf("/preview/Games"), deckyInstalled = "3.0.0", deckyEnabled = true, + ) + val modeActions = ModeSettingsActions( + onResolution = {}, + onHdr = {}, + onTouch = {}, + onSuspendPolicy = {}, + onOsc = {}, + onDirectAudio = {}, + onMic = {}, + onRenderer = {}, + onDismiss = {}, + ) + val components = ComponentsManager.Snapshot( + protons = listOf(ComponentsManager.ProtonView( + ComponentsManager.Proton("ge-proton", "GE-Proton", File("/preview/proton"), "/opt/proton", "10-20", false), + mapOf("fex" to ComponentsManager.Component("FEX-2609", "FEX-2609", "Original", null, null), + "dxvk" to ComponentsManager.Component("2.7", "DXVK 2.7", "Original", null, null), + "vkd3d" to ComponentsManager.Component("2.14", "VKD3D-Proton 2.14", "Original", null, null)), + emptyList(), false, + )), + packages = listOf(ComponentsManager.Package("dxvk-2.7.tzst", "dxvk", "2.7", "DXVK 2.7", 24_000_000)), + ) + val catalog = listOf(ComponentsManager.CatalogItem("dxvk-2.7.tzst", "dxvk", "2.7", "", 24_000_000, "")) + val drivers = listOf(DriverRow("auto", "Auto", "Recommended for this device", false), + DriverRow("turnip", "Turnip 25.3", "Android and Linux", true, DriverRow.BUNDLE)) + val gpu = GpuDriversState(gpuName = "Adreno 740", gpuFamily = "Adreno 7xx", soc = "Snapdragon 8 Gen 2", + supportText = "Turnip supported", autoStatus = "Turnip 25.3", activeBundle = "Turnip 25.3", + pairs = listOf(PairRow("turnip", "Turnip", "25.3", "Android and Linux", true, true, true, true, true)), + androidRows = drivers, linuxRows = drivers) + val drawerActions = DrawerActions( + steam = true, isHomeApp = false, androidApps = emptyList(), hudOn = true, + frameGen = FrameGen.Mode.OFF, lossless = Lossless.State.NONE, oscMode = SessionPrefs.OSC_AUTO, + onScreenButtonsVisible = true, suspendPolicy = SessionPrefs.SUSPEND_AUTO, backActionsInverted = false, + touchMode = SessionPrefs.TOUCH_AUTO, touchAuto = "Touchpad", fexPreset = "balanced", + secondScreenMode = SecondScreenMode.NONE, secondScreenDisplays = listOf(SecondScreenDisplay(2, "Secondary screen")), + selectedSecondScreenDisplay = 2, controller = controller, components = components, pipSupported = true, + onHud = {}, + onFrameGenPick = {}, + onImportLossless = {}, + onKeyboard = {}, + onHardwareKeyboard = {}, + onSteamMenu = {}, + onQam = {}, + onOsc = {}, + onSuspendPolicy = {}, + onBackActionsInverted = {}, + onTouch = {}, + onFexPreset = {}, + onSecondScreenMode = {}, + onSecondScreenDisplay = {}, + onLaunchAndroidApp = { _, _ -> }, + onBackground = {}, + onShareLogs = {}, + onStop = {}, + onClose = {}, + ) + const val TIME = 1_791_331_200_000L + val installed = AppUpdates.Installed("preview-old", 0, "0.3.0", 10, true, TIME - 86_400_000, true) + val release = AppUpdates.Release("v0.3.1", "Improve handheld layouts", "Settings and library improvements.", "preview-new", 0, "0.3.1", 11, TIME, + "", AppUpdates.Apk("DroidDeck.apk", "", 200_000_000, "", "com.droiddeck.launcher", 11, com.droiddeck.launcher.BuildConfig.RELEASE_SIGNER)) + val updatesCatalog = AppUpdates.Catalog(release, release, emptyList(), TIME, + listOf(AppUpdates.PreviewChange("preview-new", "Improve handheld layouts", "Settings and library improvements.", TIME))) +} diff --git a/app/src/debug/java/com/droiddeck/launcher/ui/PreviewSupport.kt b/app/src/debug/java/com/droiddeck/launcher/ui/PreviewSupport.kt new file mode 100644 index 000000000..23e42e687 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/ui/PreviewSupport.kt @@ -0,0 +1,54 @@ +package com.droiddeck.launcher.ui + +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.app.ActivityOptionsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry + +@Preview(name = "Handheld", widthDp = 960, heightDp = 540, apiLevel = 34) +@Preview(name = "Compact", widthDp = 480, heightDp = 360, apiLevel = 34) +@Preview(name = "Portrait", widthDp = 400, heightDp = 700, apiLevel = 34) +@Preview(name = "Large text", widthDp = 960, heightDp = 540, fontScale = 1.3f, apiLevel = 34) +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +annotation class DroidDeckPreviews + +/** Every preview uses the production theme and inert Android navigation/picker owners. */ +@Composable +internal fun PreviewHost(theme: String = Themes.GRAPHITE, content: @Composable () -> Unit) { + val owner = remember { PreviewOwner() } + CompositionLocalProvider( + LocalInspectionMode provides true, + LocalOnBackPressedDispatcherOwner provides owner, + LocalActivityResultRegistryOwner provides owner, + ) { + DroidDeckTheme(theme = theme) { + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { content() } + } + } +} + +private class PreviewOwner : OnBackPressedDispatcherOwner, ActivityResultRegistryOwner, LifecycleOwner { + override val lifecycle: Lifecycle = LifecycleRegistry(this).apply { currentState = Lifecycle.State.RESUMED } + override val onBackPressedDispatcher = OnBackPressedDispatcher() + override val activityResultRegistry = object : ActivityResultRegistry() { + override fun onLaunch(requestCode: Int, contract: ActivityResultContract, input: I, options: ActivityOptionsCompat?) = Unit + } +} diff --git a/app/src/debug/java/com/droiddeck/launcher/ui/RuntimePreviews.kt b/app/src/debug/java/com/droiddeck/launcher/ui/RuntimePreviews.kt new file mode 100644 index 000000000..837a86df2 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/ui/RuntimePreviews.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable + +@DroidDeckPreviews +@Composable +internal fun GpuDriversPreview() { + PreviewHost { + GpuDriversPanel(PreviewData.gpu, GpuDriversActions()) {} + } +} + +@DroidDeckPreviews +@Composable +internal fun ProtonsPreview() { + PreviewHost { + ProtonPage(listOf(ProtonRow("ge-proton", "GE-Proton 10-20", "10-20", false), ProtonRow("proton", "Proton Experimental", null, false)), null, null, -1, true, false, {}, {}, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun ProtonDownloadPreview() { + PreviewHost { + ProtonPage(listOf(ProtonRow("ge-proton", "GE-Proton 10-20", null, false)), "ge-proton", "Downloading", 42, true, false, {}, {}, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun ProtonsEmptyPreview() { + PreviewHost { + ProtonPage(emptyList(), null, null, -1, false, false, {}, {}, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun DriversPreview() { + PreviewHost { + DriverPage("GPU drivers", "Choose a driver", PreviewData.drivers, "auto", + listOf(DownloadRow("turnip", "Turnip 25.3", "Adreno 7xx", 42)), "Downloading", false, "Import driver", true, + {}, {}, {}, {}, {}, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun ComponentsPreview() { + PreviewHost { + ComponentsPage(PreviewData.components, PreviewData.catalog, PreviewData.TIME, "ge-proton", "dxvk", false, null, emptyMap(), + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu) + } +} + +@DroidDeckPreviews +@Composable +internal fun ComponentsLoadingPreview() { + PreviewHost { + ComponentsPage(null, PreviewData.catalog, PreviewData.TIME, "ge-proton", "dxvk", true, null, emptyMap(), + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu) + } +} + +@DroidDeckPreviews +@Composable +internal fun GpuComponentsPreview() { + PreviewHost { + ComponentsPage(PreviewData.components, PreviewData.catalog, PreviewData.TIME, "ge-proton", GPU_TAB, false, null, emptyMap(), + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu) + } +} diff --git a/app/src/debug/java/com/droiddeck/launcher/ui/SessionPreviews.kt b/app/src/debug/java/com/droiddeck/launcher/ui/SessionPreviews.kt new file mode 100644 index 000000000..327f4fc24 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/ui/SessionPreviews.kt @@ -0,0 +1,101 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import com.droiddeck.launcher.session.LoadingState + +@DroidDeckPreviews +@Composable +internal fun LoadingPreview() { + PreviewHost { + LoadingOverlay("Downloading runtime", 42, "00:32", "", false, LoadingState.Topic.RUNTIME, true, onCancel = {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun SessionStartingPreview() { + PreviewHost { + LoadingOverlay("Starting Steam", -1, "00:08", "", false, LoadingState.Topic.STEAM_STARTING, true, onCancel = {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun SessionErrorPreview() { + PreviewHost { + LoadingOverlay("Steam stopped", -1, "00:08", "Try starting Steam again.", true, LoadingState.Topic.STEAM, true, endedDetail = "Exit code 1", onRetry = {}, onShareLogs = {}, onClose = {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun KeyboardPreview() { + PreviewHost { + PcKeyboard({ _, _ -> }, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun HudPreview() { + PreviewHost { + HudText("60 FPS • GPU 72% • CPU 38%") + } +} + +@DroidDeckPreviews +@Composable +internal fun CursorPreview() { + PreviewHost { + CursorOverlay(Offset(220f, 180f), true, 2f) + } +} + +@DroidDeckPreviews +@Composable +internal fun DrawerControllerPreview() { + PreviewHost { + SessionDrawer(true, SessionDrawerPage.CONTROLLER, false, {}, PreviewData.drawerActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun DrawerSecondScreenPreview() { + PreviewHost { + SessionDrawer(true, SessionDrawerPage.SECOND_SCREEN, false, {}, PreviewData.drawerActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun DrawerDisplayPreview() { + PreviewHost { + SessionDrawer(true, SessionDrawerPage.DISPLAY, false, {}, PreviewData.drawerActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun DrawerEffectsPreview() { + PreviewHost { + SessionDrawer(true, SessionDrawerPage.EFFECTS, false, {}, PreviewData.drawerActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun DrawerGamesPreview() { + PreviewHost { + SessionDrawer(true, SessionDrawerPage.GAMES, false, {}, PreviewData.drawerActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun DrawerSessionPreview() { + PreviewHost { + SessionDrawer(true, SessionDrawerPage.SESSION, false, {}, PreviewData.drawerActions) + } +} diff --git a/app/src/debug/java/com/droiddeck/launcher/ui/SettingsPreviews.kt b/app/src/debug/java/com/droiddeck/launcher/ui/SettingsPreviews.kt new file mode 100644 index 000000000..04f879e01 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/ui/SettingsPreviews.kt @@ -0,0 +1,165 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable + +@DroidDeckPreviews +@Composable +internal fun SetupPreview() { + PreviewHost { + FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "setup") + } +} + +@DroidDeckPreviews +@Composable +internal fun ControllerMappingPreview() { + PreviewHost { + ControllerMappingPage(mapOf("a" to "b"), { _, _ -> }, {}, {}) + } +} + +@DroidDeckPreviews +@Composable +internal fun ControlsPreview() { + PreviewHost { + val host = rememberMenuHost() + SettingsPage(host, "Controller", {}, scroll = rememberScrollState()) { + ControllerRows(host, "auto", PreviewData.controller, PreviewData.frontActions.controller!!) + } + } +} + +@DroidDeckPreviews +@Composable +internal fun WidgetsPreview() { + PreviewHost { + val host = rememberMenuHost() + SettingsPage(host, "Settings controls", {}, scroll = rememberScrollState()) { + SettingsGroup("Display") { + ChoiceRow(host, "resolution", "Resolution", "Virtual screen size", listOf("720p" to "1280 × 720", "1080p" to "1920 × 1080"), "720p") {} + ToggleRow(host, "hdr", "HDR", "High dynamic range", true) {} + SliderRow("Sharpness", null, 75, 0..100) {} + ActionRow("GPU driver", null, "Manage", {}) + } + } + } +} + +@DroidDeckPreviews +@Composable +internal fun SteamDisplayPreview() { + PreviewHost { + ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 0) + } +} + +@DroidDeckPreviews +@Composable +internal fun SteamControlsPreview() { + PreviewHost { + ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 1) + } +} + +@DroidDeckPreviews +@Composable +internal fun SteamGamesPreview() { + PreviewHost { + ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 2) + } +} + +@DroidDeckPreviews +@Composable +internal fun SteamAudioPreview() { + PreviewHost { + ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 3) + } +} + +@DroidDeckPreviews +@Composable +internal fun SteamSessionPreview() { + PreviewHost { + ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 4) + } +} + +@DroidDeckPreviews +@Composable +internal fun SteamSteamPreview() { + PreviewHost { + ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 5) + } +} + +@DroidDeckPreviews +@Composable +internal fun DesktopSettingsPreview() { + PreviewHost { + ModeSettingsPage(PreviewData.mode(steam = false), PreviewData.modeActions) + } +} + +@DroidDeckPreviews +@Composable +internal fun PerformancePreview() { + PreviewHost { + PerformancePage( + cores = List(8) { CoreRow(it, if (it < 4) "Efficiency" else "Performance") }, + clientOverride = false, + clientCores = setOf(0, 1, 2, 3), + gameCores = (0..7).toSet(), + tuSysmem = false, + zinkLazy = false, + glThread = false, + noGlError = false, + noXalia = false, + fastSync = false, + syncFallback = false, + fsyncFirst = false, + gamescopeRealtime = false, + gpuClockPin = false, + prootNoSeccomp = false, + prootFastPath = false, + guestHostname = "droiddeck", + phantomWarning = null, + onClientOverride = {}, + onTuSysmem = {}, + onZinkLazy = {}, + onGlThread = {}, + onNoGlError = {}, + onNoXalia = {}, + onFastSync = {}, + onSyncFallback = {}, + onFsyncFirst = {}, + onGamescopeRealtime = {}, + onGpuClockPin = {}, + onProotNoSeccomp = {}, + onProotFastPath = {}, + onGuestHostname = {}, + onClientCore = { _, _ -> }, + onGameCore = { _, _ -> }, + onDismiss = {}, + ) + } +} + +@DroidDeckPreviews +@Composable +internal fun SetupControllerPreview() = PreviewHost { + SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 1) +} + +@DroidDeckPreviews +@Composable +internal fun SetupSessionPreview() = PreviewHost { + SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 2) +} + +@DroidDeckPreviews +@Composable +internal fun SetupLauncherPreview() = PreviewHost { + SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 3) +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt b/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt index 30ae1537b..43680d4e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.files +import androidx.compose.ui.platform.LocalInspectionMode import android.content.res.Configuration import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background @@ -122,7 +123,8 @@ class RailState internal constructor( fun rememberRailState(screenKey: String): RailState { val context = LocalContext.current // The same file the File Manager keeps its own browse settings in (no androidx.preference here). - val prefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) } + val inspecting = LocalInspectionMode.current + val prefs = remember { if (inspecting) null else context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) } val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val choseKey = "rail_${screenKey}_userChose" @@ -130,9 +132,9 @@ fun rememberRailState(screenKey: String): RailState { // Keep the landscape choice in saveable state regardless of orientation so portrait→landscape // restores it. In portrait we simply report collapsed+locked and ignore it. - var userChose by rememberSaveable(screenKey) { mutableStateOf(prefs.getBoolean(choseKey, false)) } + var userChose by rememberSaveable(screenKey) { mutableStateOf((prefs?.getBoolean(choseKey, false) ?: false)) } var landscapeCollapsed by rememberSaveable(screenKey) { - mutableStateOf(if (prefs.getBoolean(choseKey, false)) prefs.getBoolean(collapsedKey, false) else false) + mutableStateOf(if ((prefs?.getBoolean(choseKey, false) ?: false)) (prefs?.getBoolean(collapsedKey, false) ?: false) else false) } if (!isLandscape) { @@ -144,10 +146,7 @@ fun rememberRailState(screenKey: String): RailState { onToggle = { landscapeCollapsed = !landscapeCollapsed userChose = true - prefs.edit() - .putBoolean(choseKey, true) - .putBoolean(collapsedKey, landscapeCollapsed) - .apply() + prefs?.edit()?.putBoolean(choseKey, true)?.putBoolean(collapsedKey, landscapeCollapsed)?.apply() }, ) } diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt index 8efe2c6cd..ef26933d6 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt @@ -69,6 +69,7 @@ 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.platform.LocalInspectionMode import com.droiddeck.launcher.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -187,6 +188,7 @@ internal fun FileItemRow( ) { val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } val context = LocalContext.current + val inspecting = LocalInspectionMode.current val isDir = file.isDirectory val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") } // Image files show a real thumbnail instead of the generic file icon (handy when picking a @@ -195,7 +197,7 @@ internal fun FileItemRow( // For real PE executables, try to pull out the embedded application icon (async, off the main thread). var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } - if (!isDir && file.name.lowercase().endsWith(".exe")) { + if (!inspecting && !isDir && file.name.lowercase().endsWith(".exe")) { LaunchedEffect(file.absolutePath) { val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } if (bmp != null) exeIcon = bmp.asImageBitmap() @@ -364,13 +366,15 @@ internal fun FavoritesList( onJump: (File) -> Unit, onUnpin: (File) -> Unit, modifier: Modifier = Modifier, + previewFavorites: List = emptyList(), ) { val context = LocalContext.current + val inspecting = LocalInspectionMode.current val favorites = remember(favTick) { - FavoritesStore.list(context).map(::File).filter { it.exists() } + if (inspecting) previewFavorites else FavoritesStore.list(context).map(::File).filter { it.exists() } } val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) { - FavoritesStore.isFavorite(context, currentDir.absolutePath) + if (inspecting) currentDir in previewFavorites else FavoritesStore.isFavorite(context, currentDir.absolutePath) } LazyColumn(modifier = modifier) { @@ -545,10 +549,11 @@ internal fun FileGridTile( onToggleFavorite: () -> Unit = {}, onProperties: () -> Unit = {}, ) { + val inspecting = LocalInspectionMode.current val isDir = file.isDirectory val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } - if (!isDir && file.name.lowercase().endsWith(".exe")) { + if (!inspecting && !isDir && file.name.lowercase().endsWith(".exe")) { LaunchedEffect(file.absolutePath) { val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } if (bmp != null) exeIcon = bmp.asImageBitmap() diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerPreviewState.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerPreviewState.kt new file mode 100644 index 000000000..435f0d36c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerPreviewState.kt @@ -0,0 +1,6 @@ +package com.droiddeck.launcher.files + +import java.io.File + +/** Metadata for design previews. [FileManagerScreen] ignores it outside inspection mode. */ +class FileManagerPreviewState(val directory: File, val entries: List, val grid: Boolean = true, val favorites: Boolean = false) diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 9f9361ad7..393d95339 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -80,6 +80,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle @@ -109,8 +110,11 @@ fun FileManagerScreen( initialDir: File? = null, pickerTitle: String? = null, onPick: ((File) -> Unit)? = null, + preview: FileManagerPreviewState? = null, ) { val context = LocalContext.current + val inspecting = LocalInspectionMode.current + val sample = preview.takeIf { inspecting } val scope = rememberCoroutineScope() // Only matching files are shown in pick mode (directories are always shown). Empty = all files. @@ -121,14 +125,15 @@ fun FileManagerScreen( return lowerExts.any { name.endsWith(".$it") } } - val pickPrefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) } + val pickPrefs = remember { if (inspecting) null else context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) } val browsePrefs = pickPrefs val rootDir = remember { // Both modes: honour an explicit caller-supplied start dir (e.g. Log Manager's game-log // folder), else open at the INTERNAL STORAGE ROOT. Selection screens (drive-folder pick, // local component pick, imports) previously defaulted to Download which - combined with the // currentRoot floor below - trapped users in Download with no way up (reported bug). - initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0") + if (inspecting) sample?.directory ?: File("/storage/emulated/0") + else initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0") } var currentDir by remember { mutableStateOf(rootDir) } @@ -156,7 +161,7 @@ fun FileManagerScreen( } mutableStateOf(vol) } - var entries by remember { mutableStateOf(listOf()) } + var entries by remember { mutableStateOf(sample?.entries.orEmpty()) } var selectedEntry by remember { mutableStateOf(null) } var showMenuFor by remember { mutableStateOf(null) } // Clipboard holds a LIST so one paste can carry a whole selection. Cut/copy semantics are a @@ -177,9 +182,9 @@ fun FileManagerScreen( // Browse controls. Persisted so the list doesn't reset its order every time you open a folder. var searchQuery by remember { mutableStateOf("") } var showSearch by remember { mutableStateOf(false) } - var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") } - var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) } - var showHidden by remember { mutableStateOf(browsePrefs.getBoolean("fmShowHidden", true)) } + var sortBy by remember { mutableStateOf(browsePrefs?.getString("fmSortBy", "name") ?: "name") } + var sortDesc by remember { mutableStateOf(browsePrefs?.getBoolean("fmSortDesc", false) ?: false) } + var showHidden by remember { mutableStateOf(browsePrefs?.getBoolean("fmShowHidden", true) ?: true) } var showSortMenu by remember { mutableStateOf(false) } // View mode: list of cards (default) or a thumbnail grid. Density applies to the list only - // a grid tile has no second line to compact. @@ -187,9 +192,9 @@ fun FileManagerScreen( // choice persists across rotation). Grid is the default - most useful in landscape, and in // portrait GridCells.Adaptive naturally renders fewer columns (~2). Do NOT force portrait to list: // that broke the toggle on-device (tapping it did nothing in portrait). - var gridView by remember { mutableStateOf(browsePrefs.getBoolean("fmGridView", true)) } + var gridView by remember { mutableStateOf(sample?.grid ?: (browsePrefs?.getBoolean("fmGridView", true) ?: true)) } val showGrid = gridView - var compactRows by remember { mutableStateOf(browsePrefs.getBoolean("fmCompactRows", false)) } + var compactRows by remember { mutableStateOf(browsePrefs?.getBoolean("fmCompactRows", false) ?: false) } var showNewFolderDialog by remember { mutableStateOf(false) } var renameTarget by remember { mutableStateOf(null) } // Properties sheet (basic info + Read-only / Hidden toggles) target; null when closed. @@ -203,7 +208,7 @@ fun FileManagerScreen( // Favorites view: when on, a dedicated bookmarks list replaces the file list. // favTick is bumped on any add/remove/toggle so the favorites view + per-row star recompute. - var showFavorites by remember { mutableStateOf(false) } + var showFavorites by remember { mutableStateOf(sample?.favorites == true) } var favTick by remember { mutableIntStateOf(0) } // resetScroll: jump to the top of the list (true for navigation; false for in-place reloads @@ -211,7 +216,8 @@ fun FileManagerScreen( fun loadDirectory(dir: File, resetScroll: Boolean = true) { currentDir = dir // Remember the browsed directory so the next pick resumes here. - if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply() + if (inspecting) return + if (pickMode) pickPrefs?.edit()?.putString("lastFilePickerDir", dir.absolutePath)?.apply() scope.launch { val list = withContext(Dispatchers.IO) { dir.listFiles()?.toList() @@ -281,6 +287,7 @@ fun FileManagerScreen( } fun performDelete(file: File) { + if (inspecting) return scope.launch { isOperationRunning = true operationLabel = context.getString(R.string.fm_op_deleting) @@ -302,6 +309,7 @@ fun FileManagerScreen( } fun performPaste() { + if (inspecting) return val sources = clipboardFiles if (sources.isEmpty()) return val dstDir = currentDir @@ -387,6 +395,7 @@ fun FileManagerScreen( } fun performRename(file: File, newName: String) { + if (inspecting) return val target = File(file.parentFile, newName) if (target.exists()) { Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show() @@ -403,6 +412,7 @@ fun FileManagerScreen( } fun createFolder(parent: File, name: String) { + if (inspecting) return val target = File(parent, name) if (target.exists()) { Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show() @@ -430,9 +440,9 @@ fun FileManagerScreen( lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - val drives = remember(storageTick) { StorageRoots.list(context) } + val drives = remember(storageTick) { if (inspecting) emptyList() else StorageRoots.list(context) } val linuxRoot = remember(storageTick) { - com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory } + if (inspecting) null else com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory } } // ── Dialogs ── @@ -537,6 +547,7 @@ fun FileManagerScreen( pendingBulkDelete = emptyList() selectionMode = false selectedPaths = emptySet() + if (inspecting) return@TextButton operationJob = scope.launch { isOperationRunning = true var failed = 0 @@ -754,7 +765,7 @@ fun FileManagerScreen( } IconButton(onClick = { gridView = !gridView - browsePrefs.edit().putBoolean("fmGridView", gridView).apply() + browsePrefs?.edit()?.putBoolean("fmGridView", gridView)?.apply() }) { Icon( if (gridView) Icons.Filled.ViewList else Icons.Filled.GridView, @@ -780,8 +791,8 @@ fun FileManagerScreen( // Tapping the active field flips direction; a different // field switches to it ascending. if (sortBy == key) sortDesc = !sortDesc else { sortBy = key; sortDesc = false } - browsePrefs.edit().putString("fmSortBy", sortBy) - .putBoolean("fmSortDesc", sortDesc).apply() + browsePrefs?.edit()?.putString("fmSortBy", sortBy) + ?.putBoolean("fmSortDesc", sortDesc)?.apply() showSortMenu = false loadDirectory(currentDir, resetScroll = false) }, @@ -792,7 +803,7 @@ fun FileManagerScreen( text = { Text(if (compactRows) stringResource(R.string.fm_rows_comfortable) else stringResource(R.string.fm_rows_compact)) }, onClick = { compactRows = !compactRows - browsePrefs.edit().putBoolean("fmCompactRows", compactRows).apply() + browsePrefs?.edit()?.putBoolean("fmCompactRows", compactRows)?.apply() showSortMenu = false }, ) @@ -800,7 +811,7 @@ fun FileManagerScreen( text = { Text(if (showHidden) stringResource(R.string.fm_hide_hidden) else stringResource(R.string.fm_show_hidden)) }, onClick = { showHidden = !showHidden - browsePrefs.edit().putBoolean("fmShowHidden", showHidden).apply() + browsePrefs?.edit()?.putBoolean("fmShowHidden", showHidden)?.apply() showSortMenu = false loadDirectory(currentDir, resetScroll = false) }, @@ -1016,14 +1027,14 @@ fun FileManagerScreen( linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) } } val quickItems = buildList { - File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) } + File("/storage/emulated/0/Download").takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) } // The ROMs folder chosen on the main screen: what the session shows as /root/ROMs. - SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory } + (if (inspecting) "" else SessionPrefs.romsDir(context)).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory } ?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) } - File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) } - File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) } + File("/storage/emulated/0/Download/DroidDeck").takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) } + File("/storage/emulated/0/Pictures").takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) } } - val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } } + val favItems = remember(favTick) { if (inspecting) sample?.entries.orEmpty().filter { it.isDirectory } else FavoritesStore.list(context).map(::File).filter { it.exists() } } .map { d -> RailItem(d.name, Icons.Filled.Star, false) { showFavorites = false; openDrive(d) } } val locationSections = buildList { add(RailSection(stringResource(R.string.fm_rail_storage), storageItems)) @@ -1042,8 +1053,9 @@ fun FileManagerScreen( FavoritesList( currentDir = currentDir, favTick = favTick, + previewFavorites = sample?.entries.orEmpty().filter { it.isDirectory }, onPinCurrent = { - FavoritesStore.add(context, currentDir.absolutePath) + if (!inspecting) FavoritesStore.add(context, currentDir.absolutePath) favTick++ Toast.makeText(context, context.getString(R.string.fm_favorite_added, currentDir.name), Toast.LENGTH_SHORT).show() }, @@ -1052,7 +1064,7 @@ fun FileManagerScreen( openDrive(dir) }, onUnpin = { dir -> - FavoritesStore.remove(context, dir.absolutePath) + if (!inspecting) FavoritesStore.remove(context, dir.absolutePath) favTick++ Toast.makeText(context, context.getString(R.string.fm_favorite_removed, dir.name), Toast.LENGTH_SHORT).show() }, @@ -1079,7 +1091,7 @@ fun FileManagerScreen( } items(shownEntries, key = { it.absolutePath }) { file -> val isFav = remember(file.absolutePath, favTick) { - FavoritesStore.isFavorite(context, file.absolutePath) + (!inspecting && FavoritesStore.isFavorite(context, file.absolutePath)) } FileGridTile( file = file, @@ -1107,7 +1119,7 @@ fun FileManagerScreen( if (file.isDirectory) loadDirectory(file) else if (pickMode) { if (matchesPickExt(file)) { - pickPrefs.edit().putString("lastFilePickerDir", currentDir.absolutePath).apply() + pickPrefs?.edit()?.putString("lastFilePickerDir", currentDir.absolutePath)?.apply() onPick?.invoke(file) } } @@ -1126,7 +1138,7 @@ fun FileManagerScreen( onCut = { clipboardFiles = listOf(file); isCutOperation = true; showMenuFor = null }, onDelete = { selectedEntry = file; showMenuFor = null }, onToggleFavorite = { - val nowFav = FavoritesStore.toggle(context, file.absolutePath) + val nowFav = if (inspecting) false else FavoritesStore.toggle(context, file.absolutePath) favTick++ showMenuFor = null Toast.makeText( @@ -1158,7 +1170,7 @@ fun FileManagerScreen( val shown = shownEntries items(shown, key = { it.absolutePath }) { file -> val isFav = remember(file.absolutePath, favTick) { - FavoritesStore.isFavorite(context, file.absolutePath) + (!inspecting && FavoritesStore.isFavorite(context, file.absolutePath)) } FileItemRow( file = file, @@ -1188,7 +1200,7 @@ fun FileManagerScreen( if (file.isDirectory) loadDirectory(file) else if (pickMode) { if (matchesPickExt(file)) { - pickPrefs.edit().putString("lastFilePickerDir", currentDir.absolutePath).apply() + pickPrefs?.edit()?.putString("lastFilePickerDir", currentDir.absolutePath)?.apply() onPick?.invoke(file) } } @@ -1207,7 +1219,7 @@ fun FileManagerScreen( onRename = { renameTarget = file; showMenuFor = null }, isFavorite = isFav, onToggleFavorite = { - val nowFav = FavoritesStore.toggle(context, file.absolutePath) + val nowFav = if (inspecting) false else FavoritesStore.toggle(context, file.absolutePath) favTick++ showMenuFor = null Toast.makeText( diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt index fff674dc9..0e73cc640 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -58,11 +59,13 @@ internal fun FilePropertiesDialog( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val inspecting = LocalInspectionMode.current val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } - var attrs by remember(file.absolutePath) { mutableStateOf(null) } + var attrs by remember(file.absolutePath) { mutableStateOf(if (inspecting) FileAttrState(false, false, false) else null) } // Guards against a second toggle landing while the first is still being applied off-thread. var busy by remember(file.absolutePath) { mutableStateOf(false) } LaunchedEffect(file.absolutePath) { + if (inspecting) return@LaunchedEffect attrs = withContext(Dispatchers.IO) { readFileAttrs(file) } } @@ -101,7 +104,11 @@ internal fun FilePropertiesDialog( description = stringResource(R.string.fm_read_only_hint), checked = state?.readOnly == true, enabled = state != null && !busy, - onToggle = { want -> + onToggle = toggle@{ want -> + if (inspecting) { + attrs = attrs?.copy(readOnly = want) + return@toggle + } busy = true scope.launch { val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) } @@ -125,7 +132,11 @@ internal fun FilePropertiesDialog( else stringResource(R.string.fm_hidden_hint), checked = state?.hidden == true, enabled = state != null && hiddenSupported && !busy, - onToggle = { want -> + onToggle = toggle@{ want -> + if (inspecting) { + attrs = attrs?.copy(hidden = want) + return@toggle + } busy = true scope.launch { val ok = withContext(Dispatchers.IO) { setHidden(file, want) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt index 82e6936ab..aa9a6c32f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -88,6 +88,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import coil.request.ImageRequest +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.files.InAppFilePicker @@ -288,7 +289,8 @@ internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (St /** Open from the first frame; [onDismiss] once the closing animation has run. */ @Composable internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState { - val shown = remember { MutableTransitionState(false).apply { targetState = true } } + val inspecting = LocalInspectionMode.current + val shown = remember { MutableTransitionState(inspecting).apply { targetState = true } } LaunchedEffect(shown.currentState, shown.isIdle) { if (shown.isIdle && !shown.currentState && !shown.targetState) onDismiss() } return shown } @@ -482,7 +484,9 @@ private fun IconActions(onChoose: () -> Unit, clear: Pair Unit>?) { /** Icons found in [repo], looked up off the main thread once the name has settled; [selected] is ringed. */ @Composable private fun IconSuggestions(repo: String, selected: String?, onPick: (String) -> Unit) { + val inspecting = LocalInspectionMode.current val found by produceState?>(null, repo) { + if (inspecting) { value = emptyList(); return@produceState } value = null delay(500) value = withContext(Dispatchers.IO) { runCatching { UserApps.githubIcons(repo) }.getOrDefault(emptyList()) } @@ -545,8 +549,10 @@ private fun FileRow(path: String?, onChoose: () -> Unit) { /** Whether the script's folder will be linked or copied in, worked out off the main thread. */ @Composable private fun FolderNote(scriptPath: String) { + val inspecting = LocalInspectionMode.current var plan by remember(scriptPath) { mutableStateOf(null) } LaunchedEffect(scriptPath) { + if (inspecting) return@LaunchedEffect plan = withContext(Dispatchers.IO) { runCatching { UserApps.planScript(File(scriptPath)) }.getOrNull() } } val p = plan 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..6271fe5bc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.ui +import androidx.compose.ui.platform.LocalInspectionMode import android.graphics.Bitmap import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable @@ -69,7 +70,8 @@ internal class Tile( @Composable internal fun Poster(art: File?, name: String, modifier: Modifier) { val colors = MaterialTheme.colorScheme - val t = remember { Animatable(0f) } + val inspecting = LocalInspectionMode.current + val t = remember { Animatable(if (inspecting) 1f else 0f) } LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) } Box( modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f) 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 26751674e..c30eceb8f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -91,6 +91,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.droiddeck.launcher.HomeApp +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.runtime.UserApps @@ -259,7 +260,8 @@ private fun Content( val installed = s.emulators.filter { it.installed } val available = s.emulators.filter { !it.installed } val ctx = LocalContext.current - LaunchedEffect(Unit) { UserAppsState.refresh(ctx) } + val inspecting = LocalInspectionMode.current + LaunchedEffect(Unit) { if (!inspecting) UserAppsState.refresh(ctx) } val apps = UserAppsState.items var adding by rememberSaveable { mutableStateOf(false) } Rise(0) { 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 75eaab152..da0495866 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.ui import java.io.File import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import androidx.compose.runtime.staticCompositionLocalOf @@ -224,7 +225,8 @@ internal fun artBrush(h: Float) = Brush.linearGradient(listOf(tint(h), tint((h + @Composable internal fun Rise(i: Int, modifier: Modifier = Modifier, content: @Composable () -> Unit) { - val state = remember { MutableTransitionState(false) }.apply { targetState = true } + val inspecting = LocalInspectionMode.current + val state = remember { MutableTransitionState(inspecting) }.apply { targetState = true } AnimatedVisibility( visibleState = state, modifier = modifier, enter = fadeIn(Motion.tw(450, i * 60)) + slideInVertically(Motion.tw(450, i * 60)) { it / 3 }, @@ -235,6 +237,7 @@ internal fun Rise(i: Int, modifier: Modifier = Modifier, content: @Composable () @Composable private fun Modifier.staggerIn(i: Int): Modifier { + if (LocalInspectionMode.current) return this val t = remember { Animatable(0f) } LaunchedEffect(Unit) { t.animateTo(1f, Motion.tw(360, i * 30)) } return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 10.dp.toPx() } @@ -343,15 +346,16 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR } @Composable -fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) { +fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null, initialSelection: String = if (s.shortcutPicker) "games" else "steam") { val frontFocus = remember { FrontFocus() } - CompositionLocalProvider(LocalFrontFocus provides frontFocus) { FrontEndScreenBody(s, a, page, frontFocus) } + CompositionLocalProvider(LocalFrontFocus provides frontFocus) { FrontEndScreenBody(s, a, page, frontFocus, initialSelection) } } @OptIn(ExperimentalComposeUiApi::class) @Composable -private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) { - var selected by rememberSaveable { mutableStateOf(if (s.shortcutPicker) "games" else "steam") } +private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus, initialSelection: String) { + val inspecting = LocalInspectionMode.current + var selected by rememberSaveable { mutableStateOf(initialSelection) } LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" } var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) } var showDeveloperDisplayChoice by rememberSaveable { mutableStateOf(false) } @@ -394,7 +398,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com LaunchedEffect(s.steamGames.isEmpty()) { if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games" } - remember { Motion.refresh(ctx); true } + remember { if (!inspecting) Motion.refresh(ctx); true } val railSelection = when { s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" 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 3dc904acf..bcdc147b1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -76,6 +76,7 @@ import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.session.SessionPrefs +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R // The Setup page: runtime and device checks, tools, frame generation and launch settings. @@ -129,6 +130,7 @@ internal fun SetupPanel( a: FrontEndActions, onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit, + initialTab: Int = 0, ) { val host = rememberMenuHost() val ctx = LocalContext.current @@ -157,16 +159,17 @@ internal fun SetupPanel( val colors = MaterialTheme.colorScheme val pal = LocalPalette.current // Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support. - val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() } + val inspecting = LocalInspectionMode.current + val gpu = remember { if (inspecting) com.droiddeck.launcher.gpu.GpuInfo("Adreno 740", 740, com.droiddeck.launcher.gpu.GpuInfo.Family.A7XX, "Snapdragon 8 Gen 2", false) else com.droiddeck.launcher.gpu.GpuInfo.detect() } val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED - val gpuName = remember { DeviceSupport.gpuName(ctx) } + val gpuName = remember { if (inspecting) gpu.name else DeviceSupport.gpuName(ctx) } val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) val signedIn = s.offlineAccount != null var showLimitDetails by rememberSaveable { mutableStateOf(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)) - var tab by rememberSaveable { mutableStateOf(0) } + var tab by rememberSaveable { mutableStateOf(initialTab.coerceIn(0, tabs.lastIndex)) } val tabFocus = remember { List(tabs.size) { FocusRequester() } } var tabTurned by remember { mutableStateOf(false) } val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true } 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..c464e1023 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -38,6 +38,7 @@ 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.sp +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import com.droiddeck.launcher.core.GameEnvironment import com.droiddeck.launcher.core.GameEnvironmentOptions @@ -81,12 +82,13 @@ internal fun PadFocus(byPad: Boolean, target: FocusRequester) { } @Composable -private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) { +internal fun GameEnvironmentEditor(byPad: Boolean, initialConfig: GameEnvironment.Config = GameEnvironment.Config(), onClose: () -> Unit) { val context = LocalContext.current val coroutine = rememberCoroutineScope() + val inspecting = LocalInspectionMode.current val shown = rememberShown(onClose) val close = { shown.targetState = false } - var config by remember { mutableStateOf(null) } + var config by remember { mutableStateOf(if (inspecting) initialConfig else null) } var games by remember { mutableStateOf(emptyList>()) } var scope by remember { mutableStateOf("") } var otherId by remember { mutableStateOf(null) } @@ -94,12 +96,13 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) { var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(false) } var editing by remember { mutableStateOf?>(null) } - val preset = SessionPrefs.fexPreset(context) - val forceSsbs = SessionPrefs.forceSsbs(context) + val preset = if (inspecting) com.droiddeck.launcher.core.FexPreset.all.first().id else SessionPrefs.fexPreset(context) + val forceSsbs = !inspecting && SessionPrefs.forceSsbs(context) val firstFocus = remember { FocusRequester() } var editByPad by remember { mutableStateOf(false) } val dialogInput = LocalInputModeManager.current LaunchedEffect(Unit) { + if (inspecting) return@LaunchedEffect runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name } @@ -108,6 +111,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) { .onFailure { error = true } } fun save(next: GameEnvironment.Config) { + if (inspecting) { config = next; return } busy = true coroutine.launch { val result = runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.save(context, next) } } 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 c09e4070a..44890cbc6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -181,12 +181,12 @@ private enum class ModeSettingsTab(val label: Int) { } @Composable -fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { +fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions, initialTab: Int = 0) { 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) } + var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(initialTab.coerceIn(0, if (steam) ModeSettingsTab.entries.lastIndex else 2)) } val tabs = if (steam) ModeSettingsTab.entries else listOf( ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION, ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt index 10b6c00c8..b1b380f0f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.ui +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import android.content.Context @@ -53,8 +54,10 @@ fun PhantomProcessGatePage( ) { BackHandler(onBack = onDismiss) val context = LocalContext.current + val inspecting = LocalInspectionMode.current val stage by WirelessAdbPairingService.stage.collectAsState() - val environment by produceState(GateEnvironment.read(context)) { + val environment by produceState(if (inspecting) GateEnvironment(true, true) else GateEnvironment.read(context)) { + if (inspecting) return@produceState while (true) { value = GateEnvironment.read(context) kotlinx.coroutines.delay(2_000) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index 3cf190efe..bd01f9fc8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.ui +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import androidx.compose.animation.core.animateFloat @@ -148,8 +149,10 @@ fun LoadingOverlay( val active = reached.coerceAtMost(stages.lastIndex) val detail = if (percent >= 0 || readable) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail) val context = LocalContext.current + val inspecting = LocalInspectionMode.current // The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in. val games by produceState(emptyList()) { + if (inspecting) return@produceState value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) } } Box( 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 266465c10..6a75c51e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.ui +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import android.view.KeyEvent @@ -244,7 +245,8 @@ private class BelowEndProvider(private val gap: Int) : PopupPositionProvider { @Composable fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, note: String? = null, content: @Composable ColumnScope.(FocusRequester) -> Unit) { - val state = remember { MutableTransitionState(false) } + val inspecting = LocalInspectionMode.current + val state = remember { MutableTransitionState(inspecting && open) } state.targetState = open if (!state.currentState && !state.targetState && state.isIdle) return val firstItemFocus = remember { FocusRequester() } 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..7615a5a2d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.ui +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import androidx.activity.compose.BackHandler @@ -69,8 +70,10 @@ import com.droiddeck.launcher.store.StoreState private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed) @Composable -internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { +internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, previewSections: Map>? = null) { val ctx = LocalContext.current + val inspecting = LocalInspectionMode.current + val sections = previewSections.takeIf { inspecting } ?: StoreState.sections val narrow = LocalNarrowPane.current val padH = if (narrow) 16.dp else 22.dp val padV = if (narrow) 12.dp else 18.dp @@ -78,8 +81,8 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) 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() } + LaunchedEffect(s.ready) { if (!inspecting) StoreState.refresh(ctx) } + LaunchedEffect(Unit) { if (!inspecting) 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 @@ -132,7 +135,7 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) } BusyBar() when (tab) { - 0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) }) + 0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; if (!inspecting) StoreState.search("", it) }, sections = sections) 1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it }) else -> Installed(a, onOpen = { openApp = it }) } @@ -142,6 +145,7 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) /** Flatpak is not in the runtime yet: one button puts it there. */ @Composable private fun SetupCard() { + val inspecting = LocalInspectionMode.current val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -156,7 +160,7 @@ private fun SetupCard() { 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) } + PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { if (!inspecting) StoreState.setup(ctx) } } } } @@ -181,20 +185,21 @@ private fun BusyBar() { } @Composable -private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) { +private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit, sections: Map>) { + val inspecting = LocalInspectionMode.current 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) } } } } - if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) { + if (StoreState.sectionsFailed && 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) } } + Actions { SecondaryButton(stringResource(R.string.store_try_again)) { if (!inspecting) StoreState.loadSections(force = true) } } return } StoreState.SECTIONS.forEachIndexed { i, (key, title) -> - val apps = StoreState.sections[key] + val apps = 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) } } @@ -202,12 +207,13 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) { @Composable private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) { + val inspecting = LocalInspectionMode.current 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) - StoreState.search(query.trim(), category) + if (!inspecting) StoreState.search(query.trim(), category) } Rise(3) { Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { @@ -220,9 +226,9 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit, } 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) } + PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); if (!inspecting) 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) } + PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); if (!inspecting) StoreState.search(query.trim(), c.id) } } } } @@ -240,6 +246,7 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit, @Composable private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { + val inspecting = LocalInspectionMode.current val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -247,10 +254,10 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { 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 (!inspecting) 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)) + if (!inspecting) 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) } } @@ -276,7 +283,7 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { ) } 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) } + if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { if (!inspecting) StoreState.update(ctx, app.id, app.name) } SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) } } } @@ -292,7 +299,8 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val narrow = LocalNarrowPane.current - LaunchedEffect(id) { StoreState.loadDetails(id) } + val inspecting = LocalInspectionMode.current + LaunchedEffect(id) { if (!inspecting) StoreState.loadDetails(id) } val d = StoreState.details[id] val local = StoreState.installed.firstOrNull { it.id == id } val name = d?.name ?: local?.name ?: id @@ -317,15 +325,15 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: Actions { if (installed) { PrimaryButton(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) } + if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { if (!inspecting) 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 + if (confirmRemove) { confirmRemove = false; if (!inspecting) 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 (!inspecting) StoreState.install(ctx, id, name) } if (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_first), ok = false) } d?.version?.let { ActionChip("v$it", ok = false) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index f6065e1ad..9e93a28b8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -92,10 +92,10 @@ class UpdatesActions( ) @Composable -internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { +internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, installed: AppUpdates.Installed? = null) { val u = s.updates val ua = a.updates - val me = remember { AppUpdates.installed() } + val me = remember(installed) { installed ?: AppUpdates.installed() } val colors = MaterialTheme.colorScheme Column(modifier = modifier) { PageHeader(stringResource(R.string.upd_title)) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt index 9538f345e..0883b80d1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import com.droiddeck.launcher.frontend.DependencyDetector import com.droiddeck.launcher.frontend.PrefixInstalledDetector @@ -68,10 +69,12 @@ internal fun WinComponentsDialog( appKey: String, gameName: String, gameDir: File?, byPad: Boolean, /** compatdata/: what the prefix already has. [steamAppId]: a Steam title, whose appmanifest lists Steam's own redists. */ compat: File? = null, steamAppId: Int? = null, + previewComponents: List = emptyList(), onClose: () -> Unit, ) { val context = LocalContext.current val coroutine = rememberCoroutineScope() + val inspecting = LocalInspectionMode.current val shown = rememberShown(onClose) val close = { shown.targetState = false } // null while the catalog loads; empty when it could not be had. @@ -81,7 +84,7 @@ internal fun WinComponentsDialog( var installed by remember { mutableStateOf(emptySet()) } // Detector names (oalinst, physx...) already in the prefix from elsewhere. var present by remember { mutableStateOf(emptySet()) } - var picks by remember { mutableStateOf(WinComponents.picks(context, appKey)) } + var picks by remember { mutableStateOf(if (inspecting) emptyList() else WinComponents.picks(context, appKey)) } var progress by remember { mutableStateOf?>(null) } var error by remember { mutableStateOf(null) } // The component the error is about, so it shows on its own row and not only at the bottom. @@ -98,6 +101,7 @@ internal fun WinComponentsDialog( var switchFocused by remember { mutableStateOf(false) } LaunchedEffect(Unit) { + if (inspecting) { catalog = previewComponents.associateBy { it.name }; return@LaunchedEffect } val (entries, found) = withContext(Dispatchers.IO) { // The folder's own installers first, then what Steam installs with the game. val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList() @@ -112,9 +116,10 @@ internal fun WinComponentsDialog( } fun setPicks(next: List) { picks = next - coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) } + if (!inspecting) coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) } } fun toggle(id: String, on: Boolean) { + if (inspecting) { picks = if (on) picks + id else picks - id; return } // Switches stay enabled during a download - a disabled one drops the pad's focus - so a press // then is ignored here. if (progress != null) return diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index f369427a0..548588c38 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.ui +import androidx.compose.ui.platform.LocalInspectionMode import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import android.text.Editable @@ -61,7 +62,8 @@ fun WirelessAdbFixPage( onApply: (String, Int, Boolean, (String?) -> Unit) -> Unit, ) { val context = LocalContext.current - val savedAddress = remember { WirelessAdbFix.savedConnectionAddress(context) } + val inspecting = LocalInspectionMode.current + val savedAddress = remember { if (inspecting) null else WirelessAdbFix.savedConnectionAddress(context) } var step by rememberSaveable { mutableStateOf(if (savedAddress == null) 0 else 1) } var pairingAddress by rememberSaveable { mutableStateOf("") } var connectionAddress by rememberSaveable { mutableStateOf(savedAddress.orEmpty()) } diff --git a/app/src/testDebug/java/com/droiddeck/launcher/ui/PreviewRenderTest.kt b/app/src/testDebug/java/com/droiddeck/launcher/ui/PreviewRenderTest.kt new file mode 100644 index 000000000..90fdacd39 --- /dev/null +++ b/app/src/testDebug/java/com/droiddeck/launcher/ui/PreviewRenderTest.kt @@ -0,0 +1,80 @@ +package com.droiddeck.launcher.ui + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Looper +import android.view.inspector.WindowInspector +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.ui.test.junit4.createComposeRule +import com.droiddeck.launcher.files.filePreviewCases +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import java.time.Duration + +/** Render the actual preview entry points, including dialogs and their inspection-only paths. */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-land-mdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +internal class PreviewRenderTest(private val case: PreviewCase) { + @get:Rule val compose = createComposeRule() + + @Test fun rendersContentWithoutDeviceOrRuntime() = render("handheld") + + @Test @Config(qualifiers = "w480dp-h360dp-land-mdpi") + fun rendersCompactLayout() = render("compact") + + @Test @Config(qualifiers = "w400dp-h700dp-port-mdpi") + fun rendersPortraitLayout() = render("portrait") + + private fun render(size: String) { + if (case.name == "CustomResolution") { + // Compose 1.6's AlertDialog + weighted text fields never report idle to Espresso here. + // Render this dialog with bounded native frames instead of changing production UI. + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup() + try { + activity.get().setContent { case.content() } + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1000)) + saveWindow(size) + } finally { + activity.pause().stop().destroy() + } + return + } + compose.mainClock.autoAdvance = false + compose.setContent { case.content() } + compose.mainClock.advanceTimeBy(1000) + compose.waitForIdle() + compose.runOnIdle { saveWindow(size) } + } + + private fun saveWindow(size: String) { + // Dialogs have their own root; capture their content rather than the empty host. + val view = WindowInspector.getGlobalWindowViews().last() + assertTrue("${case.name}: empty bounds", view.width > 0 && view.height > 0) + val image = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + view.draw(Canvas(image)) + val pixels = IntArray(image.width * image.height) + image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height) + assertTrue("${case.name}: blank preview", pixels.any { it != pixels.first() }) + val output = File("build/preview-renders/$size/${case.name}.png") + output.parentFile?.mkdirs() + output.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) } + image.recycle() + } + + companion object { + @JvmStatic @Parameters(name = "{0}") + fun previews(): List> = (screenPreviewCases + filePreviewCases).map { arrayOf(it) } + } +} diff --git a/docs/compose-previews.md b/docs/compose-previews.md new file mode 100644 index 000000000..a2c8843f1 --- /dev/null +++ b/docs/compose-previews.md @@ -0,0 +1,49 @@ +# Compose previews + +Select the **debug** build variant in Android Studio, then open a preview file under +`app/src/debug/java/com/droiddeck/launcher/` and choose **Split** or **Design**. +Build the project after syncing Gradle. Previews call the production composables; +there is no separate mock UI to keep in sync. + +| File | Coverage | +| --- | --- | +| `ui/LauncherPreviews.kt` | Steam home, runtime missing/downloading, populated/empty library, game details, desktop, emulator, Android apps, Store, Updates, alternate theme | +| `ui/SettingsPreviews.kt` | All four Setup tabs, all six Steam settings tabs, desktop settings, controller/mapping, performance, shared settings widgets | +| `ui/RuntimePreviews.kt` | Proton installed/empty/downloading, components/loading, GPU drivers and driver downloads | +| `ui/SessionPreviews.kt` | Every session drawer page, loading/starting/error screens, PC keyboard, HUD, cursor | +| `ui/DialogPreviews.kt` | Add/edit app, confirmation, custom resolution, ROM folder, environment variables, Windows components, display chooser, wireless ADB, process-limit help | +| `files/FilePreviews.kt` | File manager grid/list/empty/favorites, file/folder pickers, properties | + +`@DroidDeckPreviews` shows handheld landscape (960×540 dp), compact landscape +(480×360 dp), portrait (400×700 dp), and larger text. The Paper theme has a separate +launcher preview. Dimensions are Android layout units, not physical display pixels. + +Edit the real UI components to adjust layouts. Edit `PreviewData.kt` for sample +screen state and callbacks, and `FilePreviews.kt` for file metadata. Add a preview +for each new screen or materially different state. Add its entry to +`PreviewCases.kt` or `filePreviewCases` so it is covered by the render check. + +Preview fixtures and tooling are debug-only. `PreviewHost` supplies the production +theme, inspection mode, and inert navigation/file-picker owners. Inspection paths +skip automatic runtime, directory, device, and network operations and settle +entrance animations so content is visible immediately. File manager mutation +commands are disabled in inspection mode. Previews are for layout and local UI +interaction; they do not validate installation, pairing, launching, or controller +input latency. + +The native game surface, Termux terminal, on-screen gamepad, and second-screen +View controls are outside this Compose preview set. Their surrounding Compose +menus are covered. These components still need device validation. + +Run the preview render smoke checks: + +```sh +./gradlew app:testDebugUnitTest --tests '*PreviewRenderTest' +``` + +The checks use Robolectric native graphics with an ordinary `Application`, render +the same 68 preview entry points at handheld, compact, and portrait sizes, reject +empty/solid-color output, and write PNGs to +`app/build/preview-renders/`. This catches composition/render errors without starting +the Linux runtime. Android Studio uses Layoutlib, so the smoke check complements +Studio preview validation rather than replacing it.