Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 938a3cb543 wip: page return rule pad bridge changes 2026-10-14 12:16:24 -04:00
xXJsonDeruloXx 964c3628b0 fix(ui): chip selection outlines draw inside the chip so a scrolling row no longer cuts them 2026-10-11 14:59:46 -04:00
xXJsonDeruloXx 60ea4a3b99 chore(ui): drop three strings the Desktop rework left unused 2026-10-11 14:53:57 -04:00
xXJsonDeruloXx 13f4cc8cf4 fix(ui): Desktop's emulators show before the games' scan, and the shell badge goes 2026-10-11 14:47:23 -04:00
xXJsonDeruloXx e27918e51c feat(ui): Flathub and Stores are always on, and Desktop gets Flathub and + beside Open desktop 2026-10-11 14:42:54 -04:00
xXJsonDeruloXx e25d44c23d feat(ui): swipe the settings sheet with a finger, label its handle, and keep Desktop to one hero with a dock 2026-10-11 14:34:51 -04:00
xXJsonDeruloXx 87e288a9ca fix(ui): LB / RB keep focus everywhere, rows clear the chips, and a bobbing handle replaces the lip 2026-10-11 14:19:56 -04:00
xXJsonDeruloXx b9d4ad2b0a feat(ui): refine the sheet's beats, the lip's return and the hop's sink and swell 2026-10-11 14:11:04 -04:00
xXJsonDeruloXx 1b75436bc9 chore: merge main into the motion work 2026-10-11 14:01:37 -04:00
xXJsonDeruloXx 3a096c074e feat(ui): Flathub becomes a page of Desktop's instead of its own rail section 2026-10-11 13:50:55 -04:00
xXJsonDeruloXx 42b3127486 fix(ui): the wall's fades stay at the pane's size while the wall sinks 2026-10-11 13:44:21 -04:00
xXJsonDeruloXx 1e291dfdf4 fix(ui): a pad keeps its place in the lifted sheet across tabs and back to Play 2026-10-11 13:32:29 -04:00
xXJsonDeruloXx bb4aee7027 feat(ui): Desktop in the Steam tab's shape, with its own lifted sheet 2026-10-11 11:38:42 -04:00
xXJsonDeruloXx 308f53432c feat(ui): Flathub in the stores' style, in its own teal 2026-10-11 11:23:46 -04:00
xXJsonDeruloXx 18bf767694 feat(ui): Setup sorted by scope, with the Stores settings stepped out of their cog 2026-10-10 18:52:28 -04:00
xXJsonDeruloXx 0b7ba68e38 feat(ui): Components as one stack of layers with a fixed detail frame 2026-10-10 18:48:33 -04:00
xXJsonDeruloXx 68fbcd6f39 feat(ui): lift the Play sheet out from under Play 2026-10-10 18:35:08 -04:00
xXJsonDeruloXx 4c2ed55e9f feat(ui): shared chip glide and flight dot, and the hop between sections 2026-10-10 18:21:36 -04:00
xXJsonDeruloXx adc3369975 fix(ui): a page only drains into its control when that control comes back 2026-10-10 18:03:19 -04:00
45 changed files with 4422 additions and 1827 deletions

No files matched your search

@@ -46,7 +46,6 @@ import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.ui.ProtonPage
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
@@ -55,7 +54,6 @@ import com.droiddeck.launcher.core.WifiDiscovery
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage
import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.ConfirmDialog
@@ -124,7 +122,6 @@ class MainActivity : ComponentActivity() {
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
@@ -166,8 +163,6 @@ class MainActivity : ComponentActivity() {
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var gameStoresEnabled by mutableStateOf(false)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
/** A page a notification asked for ("stores:downloads", "app:<id>"), until the front end has gone there. */
@@ -338,7 +333,7 @@ class MainActivity : ComponentActivity() {
romsDir = path
}
}
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
// The mode whose lifted sheet last read its settings, with what it shows; refreshed by loadModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
@@ -539,8 +534,6 @@ class MainActivity : ComponentActivity() {
requestedOrientation = orientation
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
animationsEnabled = SessionPrefs.animationsEnabled(this)
storeEnabled = SessionPrefs.storeEnabled(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
storesShowMature = SessionPrefs.storesShowMature(this)
@@ -549,11 +542,8 @@ class MainActivity : ComponentActivity() {
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showComponents -> { { ComponentsHost() } }
showMapping -> { { MappingHost() } }
else -> null
@@ -572,7 +562,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
appScale = appScale,
@@ -597,8 +587,6 @@ class MainActivity : ComponentActivity() {
orientation = orientation,
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
navigate = navRequest,
@@ -683,9 +671,10 @@ class MainActivity : ComponentActivity() {
},
// The activity re-attaches to the session that is running; nothing restarts.
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
onSteamSettings = { loadModeSettings(SessionService.MODE_STEAM) },
onRemoveDesktop = { removeDesktop() },
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
onDesktopSettings = { loadModeSettings(SessionService.MODE_DESKTOP) },
modeSheet = { mode, tab, extras -> ModeSheetHost(mode, tab, extras) },
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
onRuntime = { onRuntimeButton() },
@@ -698,10 +687,11 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onPerformance = { refreshCores(); showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onBrowseFiles = { dir ->
@@ -736,7 +726,7 @@ class MainActivity : ComponentActivity() {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
@@ -759,8 +749,6 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = on
applyLauncherFullscreen()
},
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
onNavigated = { navRequest = null },
@@ -846,7 +834,7 @@ class MainActivity : ComponentActivity() {
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
@@ -966,14 +954,12 @@ class MainActivity : ComponentActivity() {
}
}
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
/** On the Components page the pad's LB / RB step through the stack's layers, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
components.compComp = com.droiddeck.launcher.ui.layerStep(components.compComp, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
}
return true
}
@@ -995,7 +981,7 @@ class MainActivity : ComponentActivity() {
refreshWifiDiscovery()
// Back from a store's sign-in page: the Stores chips show the account at once. A store
// install finishing while this screen is up rebuilds the Games list through the listener.
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
@@ -1126,14 +1112,8 @@ class MainActivity : ComponentActivity() {
}, "game-saves-action").start()
}
private fun openProtons() {
settingsMode = null
showPerformance = false
showComponents = false
showMapping = false
showProtons = true
protons.refreshProtons()
}
/** The Protons DroidDeck installs are the Components page's Proton layer. */
private fun openProtons() = openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.PROTON_TAB)
private fun refreshController() {
controllerSettings = ControllerPrefs.read(this)
@@ -1153,13 +1133,18 @@ class MainActivity : ComponentActivity() {
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
focusComponentsContent = focusContent
if (tab != null) components.compComp = tab
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
// The Proton layer's options for every Proton, as they are now.
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
components.refreshComponents(snapshotFirst = true)
drivers.refreshDrivers()
protons.refreshProtons()
}
private fun importComponent(uri: Uri) {
@@ -1193,7 +1178,8 @@ class MainActivity : ComponentActivity() {
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { showComponents = false },
// Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came.
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
@@ -1210,23 +1196,31 @@ class MainActivity : ComponentActivity() {
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
),
protons = com.droiddeck.launcher.ui.ProtonsState(
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
runtimeReady = ready && !busy, sessionRunning = SessionState.running,
),
protonActions = com.droiddeck.launcher.ui.ProtonsActions(
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
onRemove = { id -> protons.removeProton(id) },
),
protonOptions = {
com.droiddeck.launcher.ui.ProtonOptions(
syncBackend = SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback), fexPreset = fexPreset, forceSsbs = forceSsbs,
onSyncBackend = { id ->
SessionPrefs.setSyncBackend(this, id)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
)
},
)
}
@Composable
private fun ProtonHost() {
ProtonPage(
rows = protons.protonRows,
busyId = protons.protonBusyId,
stage = protons.protonStage,
percent = protons.protonPercent,
runtimeReady = ready && !busy,
sessionRunning = SessionState.running,
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
onRemove = { id -> protons.removeProton(id) },
onBack = { showProtons = false },
)
// A Proton installed or removed changes the stack's list of Protons.
androidx.compose.runtime.LaunchedEffect(protons.protonBusyId) { if (protons.protonBusyId == null) components.refreshComponents() }
}
private fun refreshPackages() {
@@ -1273,8 +1267,10 @@ class MainActivity : ComponentActivity() {
}
@Composable
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
private fun ModeSheetHost(mode: String, tab: String, extras: com.droiddeck.launcher.ui.SheetExtras) {
// Read for the other mode last (or not yet): what this one shows is read as it opens.
if (settingsMode != mode) return
com.droiddeck.launcher.ui.ModeSheetRows(
ModeSettings(
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
@@ -1394,8 +1390,9 @@ class MainActivity : ComponentActivity() {
onPickDeckyPluginZip = {
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
},
onDismiss = { settingsMode = null },
onDismiss = {},
),
tab, extras,
)
}
@@ -1496,13 +1493,9 @@ class MainActivity : ComponentActivity() {
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private fun openModeSettings(mode: String) {
private fun loadModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
@@ -1530,8 +1523,8 @@ class MainActivity : ComponentActivity() {
gameStorage = SessionPrefs.gameStorage(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
settingsMode = mode
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it animates in.
// The sheet lifts at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it rises.
Thread({
drivers.refreshDrivers()
val storage = GameStorage.options(this)
@@ -1607,7 +1600,10 @@ class MainActivity : ComponentActivity() {
val scanGeneration = ++libraryScanGeneration
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
// Steam's own games first, from their manifests alone (quick), beside the added games
// The emulators first: they need no walk of the games, and Desktop's dock is made of them.
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
// Steam's own games next, from their manifests alone (quick), beside the added games
// already listed: a game Steam installed during the session shows as the session ends,
// without waiting for the added games' walk below. Its Windows components' list is
// queued then too, on the low-priority worker, so its page is usually ready.
@@ -1628,7 +1624,6 @@ class MainActivity : ComponentActivity() {
// so a partial list first would shuffle every capsule when the rest arrived.
// LibraryCache covers the wait.
val games = if (ready) Library.launchableGames(this) else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
// Once per app start: the Steam games' Windows components from Steam's lists.
@@ -1643,7 +1638,6 @@ class MainActivity : ComponentActivity() {
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
emulatorList = emus
shortcutLibraryScanning = false
handleGameLink(all)
}
@@ -138,6 +138,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun openDeckRing(why: String) {
val deck = SessionState.deckPad
val opened = if (deck) padBridge?.start() else null
// Every pad already connected is a player from the start, before its first press.
padBridge?.claimConnected()
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
null -> ", left for first input"
true -> ", ring open"
@@ -310,6 +312,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val deviceListener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
padBridge?.claimConnected()
updateOnScreenControls()
updatePointerCapture()
}
@@ -29,7 +29,9 @@ import java.util.concurrent.atomic.AtomicIntegerArray;
* Guide-then-A chord the client itself answers.
*
* <p>Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a
* Steam session only the client reads (libfakeinput) and hands games through Steam Input.
* Steam session only the client reads (libfakeinput) and hands games through Steam Input. Slot 0
* is the device's own controls wherever it has them: a Bluetooth pad pressed first used to take
* it, and played as the handheld while its built-in pad became player 2.
*/
public final class PadBridge {
private static final String TAG = "PadBridge";
@@ -101,6 +103,27 @@ public final class PadBridge {
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
public static final int NO_CONTROLLER = -1;
/**
* Whether this controller is the device's own controls, which play in slot 0: the one Android
* calls built-in, else - a handheld whose pad Android calls external, as AYN's present theirs -
* the controller connected longest, there since boot where it is the device's own. A phone
* with only connected pads has its first one there.
*/
static boolean isBuiltIn(InputDevice device) {
int own = NO_CONTROLLER;
boolean ownInternal = false;
for (int id : InputDevice.getDeviceIds()) {
InputDevice d = InputDevice.getDevice(id);
if (!isFromController(d)) continue;
boolean internal = !d.isExternal();
if (own == NO_CONTROLLER || (internal && !ownInternal) || (internal == ownInternal && id < own)) {
own = id;
ownInternal = internal;
}
}
return device.getId() == own;
}
/**
* The Android input device id of the physical controller that last drove any slot, or
* {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent).
@@ -411,6 +434,19 @@ public final class PadBridge {
}
}
/**
* Gives every connected controller its slot now, so the client lists each one as it is
* connected instead of on its first press: a further player's pad is plugged in, at rest.
*/
public synchronized void claimConnected() {
for (int id : InputDevice.getDeviceIds()) {
InputDevice device = InputDevice.getDevice(id);
if (!isFromController(device)) continue;
int slot = slotFor(device);
if (slot > 0 && playerWriters[slot] == null) publish(slot);
}
}
/** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */
public synchronized void onDeviceRemoved(int deviceId) {
for (int slot = 0; slot < SLOTS; slot++) {
@@ -471,23 +507,47 @@ public final class PadBridge {
publish();
}
/** The slot this controller plays in: the one it has, else the first free one, else player 1's. */
/**
* The slot this controller plays in: the one it has, else slot 0 for the device's own controls
* and a further player's slot for a connected pad (slot 0 too while the device has no controls
* of its own), else player 1's.
*/
private int slotFor(InputDevice device) {
int id = device.getId();
if (owners[0] == id) return 0;
for (int slot = 1; slot < SLOTS; slot++) {
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] == id) return slot;
}
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
}
for (int slot = 0; slot < SLOTS; slot++) {
if (isBuiltIn(device)) {
// A connected pad playing as the device's own controls moves to a player's slot of its own.
int was = owners[0];
if (was != NO_CONTROLLER) {
free(0);
InputDevice other = InputDevice.getDevice(was);
if (other != null && !isBuiltIn(other)) {
int to = claim(other, 1);
if (to > 0) publish(to);
}
}
owners[0] = id;
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player 1, the device's own controls");
return 0;
}
int slot = claim(device, 1);
return slot >= 0 ? slot : 0;
}
/** Takes the first free slot from {@code from} for this controller, or -1 if none is free. */
private int claim(InputDevice device, int from) {
for (int slot = from; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER) continue;
owners[slot] = id;
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1));
owners[slot] = device.getId();
Log.i(TAG, "\"" + device.getName() + "\" (id " + device.getId() + ") is player " + (slot + 1));
return slot;
}
return 0;
return -1;
}
private PadState stateFor(int slot) {
@@ -110,6 +110,9 @@ object DesktopCatalog {
return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists()
}
/** The installed desktop's size on disk; off the main thread. */
fun desktopSize(context: Context): Long = DesktopRemoval.installedSize(context)
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) =
DesktopRemoval.cleanLegacy(context, listener)
@@ -19,6 +19,14 @@ internal object DesktopRemoval {
private fun protected(context: Context, root: File) =
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
/**
* What the desktop's own files take on disk, from the same inventory its removal uses (each
* file's size); the bundled apps' and emulators' shared files are not counted. Reads an asset:
* call it off the main thread.
*/
fun installedSize(context: Context): Long =
lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L }
/** Called before a guest starts or just after KDE extraction; retries after a process death. */
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
val root = LinuxRuntime.rootDir(context)
@@ -68,20 +68,6 @@ object SessionPrefs {
prefs(context).edit().putBoolean("animations", on).apply()
}
/** The Flathub Store (beta): its rail item. Off by default. */
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
fun setStoreEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("storeEnabled", on).apply()
}
/** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false)
fun setGameStoresEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
}
/** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */
fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast"
@@ -48,6 +48,9 @@ object StoreState {
private set
var searching by mutableStateOf(false)
private set
/** An app whose Flathub page was asked for from outside the store (its card on Desktop), once. */
var openRequest by mutableStateOf<String?>(null)
private var searchSeq = 0
val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,455 @@
package com.droiddeck.launcher.ui
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.zIndex
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.UserApps
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.store.UserAppsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// The Desktop tab in the Steam tab's shape: a tilted wall of little windows behind the word and
// one big Open desktop, with Play's lip and lifted sheet under it (Display, Controls, Session and
// Files). Under the hero the page scrolls to two shelves - the apps that run in the desktop, and
// what can be added - and the hero folds into a slim header as it goes.
/** The desktop's own chips: its session settings, then Files. */
private val DESKTOP_TABS = modeSheetTabs(steam = false) + SheetTab.FILES
@Composable
internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, onSelect: (String) -> Unit) {
val ctx = LocalContext.current
val density = LocalDensity.current
val narrow = LocalNarrowPane.current
val colors = MaterialTheme.colorScheme
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
// Flathub's installed apps sit with the others on the dock.
LaunchedEffect(s.ready) { com.droiddeck.launcher.store.StoreState.refresh(ctx) }
val installed = s.emulators.filter { it.installed }
val apps = UserAppsState.items
var adding by rememberSaveable { mutableStateOf(false) }
val mode = SessionService.MODE_DESKTOP
val tabs = DESKTOP_TABS.map { it to sheetTabLabel(it) }
val open = if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop)
// Opened from a card: the page floods out of it, its picture on the tile.
fun openFrom(at: Rect?, art: Any?, page: String) {
at?.let { PageOrigin.mark(Origin(it, 16f * density.density, art = art)) }
onSelect(page)
}
// The Steam tab's shape, the whole pane: the dock of apps along the top of the wall, the word
// and Open desktop at the foot, and nothing under the settings sheet but the hero itself.
LiftHero(
key = "desktop", tabs = tabs,
onOpen = a.onDesktopSettings, onPrimary = a.onDesktop,
modifier = modifier.clipToBounds(),
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
background = {
DesktopWall(installed.map { it.iconRes } + apps.mapNotNull { it.icon })
},
scrim = { lifted ->
WallFade()
// Over the fades, so the cards read at full strength; gone as the sheet lifts, so
// nothing but the wall is behind it.
Box(Modifier.graphicsLayer { val v = lifted(); alpha = 1f - v; translationY = -12.dp.toPx() * v }) {
DesktopDock(s, a, installed, apps, openFrom = ::openFrom)
}
},
words = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// The word at the wordmark's size.
Text(
stringResource(R.string.rail_desktop), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp,
fontWeight = FontWeight.Black, color = colors.onBackground,
)
BusyChip(s)
}
},
button = { click ->
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click)
},
beside = {
// Flathub, whose apps run in the desktop: its storefront floods out of this button.
HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") }
// Add your own app or game.
HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true }
},
rows = { tab ->
if (tab == SheetTab.FILES) DesktopFiles(s, a)
else a.modeSheet(mode, tab, SheetExtras())
},
)
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
}
/**
* The dock along the top of the Desktop hero: what runs in the desktop (emulators installed, apps
* added, Flathub's installed apps), then the emulators that can be added - one
* row of cards that scrolls sideways, over the wall. It sinks back with the wall as the sheet lifts.
*/
@Composable
private fun DesktopDock(
s: FrontEndState, a: FrontEndActions, installed: List<Library.Emulator>, apps: List<UserApps.App>,
openFrom: (Rect?, Any?, String) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val available = s.emulators.filter { !it.installed }
val flatpaks = com.droiddeck.launcher.store.StoreState.installed
val keys = installed.map { "emu:${it.id}" } + apps.map { "user:${it.key}" } + flatpaks.map { "flatpak:${it.id}" }
// Where the last of your apps sits, for a new emulator's dot to land after it.
var lastAt by remember { mutableStateOf<Rect?>(null) }
var dockAt by remember { mutableStateOf<Rect?>(null) }
val seen = remember { mutableSetOf<String>() }
val mounted = remember { booleanArrayOf(false) }
LaunchedEffect(Unit) { mounted[0] = true }
val scroll = rememberScrollState()
// Nothing yet (the emulators' catalog still on its way): no heading over an empty row.
if (keys.isEmpty() && available.isEmpty() && UserAppsState.working == null && UserAppsState.lastError == null) return
Column(Modifier.fillMaxWidth().padding(top = if (narrow) 14.dp else 22.dp)) {
Text(
stringResource(R.string.user_apps_section).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(start = if (narrow) 20.dp else 44.dp, bottom = 8.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth().onGloballyPositioned { dockAt = it.boundsInRoot() }
.edgeFade(scroll).horizontalScroll(scroll).padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 2.dp, bottom = 4.dp),
) {
keys.forEachIndexed { i, k ->
key(k) {
// A card that arrives after the dock is up (an emulator just installed) opens in.
val state = remember { MutableTransitionState(!mounted[0] || k in seen) }.apply { targetState = true }
LaunchedEffect(Unit) { seen += k }
AnimatedVisibility(state, enter = expandHorizontally(Motion.sp(0.75f)) + fadeIn(Motion.tw(300, 80))) {
Box(if (i == keys.lastIndex) Modifier.onGloballyPositioned { lastAt = it.boundsInRoot() } else Modifier) {
val e = installed.firstOrNull { "emu:${it.id}" == k }
val flatpak = flatpaks.firstOrNull { "flatpak:${it.id}" == k }
val app = apps.firstOrNull { "user:${it.key}" == k }
if (e != null) {
val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() }
DesktopCard(
k, e.name,
if (e.id == "retroarch") system else "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size),
art = e.iconRes, first = i == 0,
) { at -> openFrom(at, e.iconRes, k) }
} else if (flatpak != null) DesktopCard(
k, flatpak.name, flatpak.summary ?: stringResource(R.string.flathub_name),
art = flatpak.icon ?: com.droiddeck.launcher.store.FlathubApi.iconUrl(flatpak.id), first = i == 0,
) { at ->
// Its page on Flathub: open, update or remove it there.
com.droiddeck.launcher.store.StoreState.openRequest = flatpak.id
openFrom(at, flatpak.icon, "store")
} else if (app != null) DesktopCard(k, app.name, stringResource(app.kind.label()), art = app.icon, fallback = if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, first = i == 0) { at ->
openFrom(at, app.icon, k)
}
}
}
}
}
// Between yours and what can be added: a thin rule.
if (keys.isNotEmpty()) Box(Modifier.padding(horizontal = 4.dp).width(1.dp).height(120.dp).background(pal.line2))
available.forEach { e ->
key(e.id) {
val pkg = Library.packageId(e.id)?.let { id -> s.packages?.firstOrNull { it.id == id } }
val busyHere = pkg != null && s.packageBusyId == pkg.id
val canGet = pkg != null && !s.busy && s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning
val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() }
DesktopCard(
"emu:${e.id}", e.name, if (busyHere) (s.packageStage ?: stringResource(R.string.store_installing)) else system,
art = e.iconRes, badge = if (busyHere) null else stringResource(R.string.comp_get),
progress = if (busyHere) s.packagePercent else null, first = keys.isEmpty() && e == available.first(),
) { at ->
// Get installs it there and then, and a dot flies to where its card will be;
// when it cannot (no runtime, a session running), its page says why.
if (canGet && pkg != null && at != null) {
a.onInstallPackage(pkg.id)
val end = lastAt
val row = dockAt
Flights.fly(Flight(at, to = {
val r = row ?: return@Flight null
Offset((end?.right ?: r.left) + 70.dp.toPx(), r.center.y)
}))
} else openFrom(at, e.iconRes, "emu:${e.id}")
}
}
}
}
if (UserAppsState.working != null || UserAppsState.lastError != null) {
Box(Modifier.padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 6.dp)) { UserAppsProgressLine() }
}
}
}
/**
* A square button the big one's height beside it on the hero: an icon in [tint] (the palette's
* signal when null). [onClick] gets its bounds, for a page to flood out of.
*/
@Composable
private fun HeroIconButton(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, tint: Color?, id: String, onClick: (Rect?) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.95f else if (hot) 1.04f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "heroIcon")
val shape = RoundedCornerShape(12.dp)
val accent = tint ?: pal.signal
var at by remember { mutableStateOf<Rect?>(null) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(56.dp).paneItem("btn:$id").onGloballyPositioned { at = it.boundsInRoot() }
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape).background(if (hot) accent.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.92f))
.glideBorder(hot, shape, accent, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(at) }
.controllerConfirm { onClick(at) }
.semantics { contentDescription = description },
) { Icon(icon, null, tint = accent, modifier = Modifier.size(26.dp)) }
}
/**
* The hero's wall: little windows tilted and drifting as Steam's capsules do, each a window with a
* bar along its foot and an emulator's or app's icon inside; blank windows when there are none.
*/
@Composable
private fun DesktopWall(icons: List<Any>) {
val pal = LocalPalette.current
val ground = remember(pal) { Brush.linearGradient(listOf(pal.surfaceVariant, pal.surface)) }
TiltedWall(icons.size, if (icons.isEmpty()) 75_000 else 50_000, capW = 132.dp, capH = 84.dp, gap = 14.dp, corner = 8.dp) { i ->
WallCapsule(ground, edge = pal.line2, icon = icons.getOrNull(i), bar = pal.background.copy(alpha = 0.7f))
}
}
/**
* One thing that runs in the desktop: its icon over a blurred, darkened copy of itself, a status on
* the art (Get, or how far an install is), and under it its name on up to two lines and what it
* is. [onClick] gets the art's bounds, for a page to flood out of or a dot to fly from.
*/
@Composable
private fun DesktopCard(
key: String, name: String, detail: String, art: Any?,
fallback: androidx.compose.ui.graphics.vector.ImageVector = Icons.Outlined.Apps,
first: Boolean = false, badge: String? = null, progress: Int? = null, accent: Boolean = false, tint: Color? = null,
onClick: (Rect?) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "deskCard")
val shape = RoundedCornerShape(16.dp)
var artAt by remember { mutableStateOf<Rect?>(null) }
val width = if (LocalNarrowPane.current) 116.dp else 132.dp
val artH = width * 0.62f
val ground = tint?.copy(alpha = 0.25f) ?: if (accent) pal.signal.copy(alpha = 0.12f) else colors.surfaceVariant
Column(
Modifier.width(width).paneItem("tile:desktop:$key").then(if (first) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(artAt) }
.controllerConfirm { onClick(artAt) }
.semantics { contentDescription = name },
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { artAt = it.boundsInRoot() }
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(ground),
) {
when (art) {
is Int -> {
Image(
painterResource(art), null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f }
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
Image(painterResource(art), null, modifier = Modifier.size(artH * 0.6f))
}
null -> Icon(fallback, null, tint = tint ?: if (accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.42f))
else -> {
AsyncImage(
model = art, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f }
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.6f))
}
}
Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) {
if (progress != null) Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) {
if (progress >= 0) CircularProgressIndicator(progress = { progress / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp))
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
}
else if (badge != null) Text(
badge, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(pal.signal).padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) {
Text(name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp))
}
}
}
/**
* The desktop sheet's Files chip: the file manager and the ROMs folder (both from Setup's old
* tools), how much the desktop takes, and Uninstall - its only home - which asks in red first.
*/
@Composable
private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
SettingsGroup(stringResource(R.string.setup_tool_files)) {
SettingsRow(stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint)) {
SecondaryButton(stringResource(R.string.store_open), compact = true, onClick = a.onFiles)
}
SettingsRow(stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint)) {
SecondaryButton(stringResource(R.string.setup_choose), compact = true, onClick = a.onRoms)
}
}
if (s.desktopPresent) SettingsGroup(stringResource(R.string.content_linux_desktop)) {
val size by produceState<Long?>(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } }
SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {}
SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) {
UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop)
}
}
}
/** Uninstall the desktop: are you sure steps out of it in red, Keep first and focused. */
@Composable
private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) {
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.common_uninstall)
val title = stringResource(R.string.desktop_uninstall_title)
val note = stringResource(R.string.desktop_remove_text)
val keep = stringResource(R.string.flathub_keep)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled, compact = true) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
handle = { Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(title)
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) {
StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
}
})
}
}
}
/** The add or remove under way, or why the last one failed. */
@Composable
private fun UserAppsProgressLine() {
val colors = MaterialTheme.colorScheme
val working = UserAppsState.working
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
if (working != null) {
val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting)
val percent = UserAppsState.percent
Text(
if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (percent >= 0) androidx.compose.material3.LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
} else UserAppsState.lastError?.let { Note(it) }
}
}
@@ -56,9 +56,21 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import java.io.File
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
// Cover art: posters, the art grid and its tiles.
/** Where each art tile sits, root px, by its key: a page opened from one floods out of it. */
internal object TileBounds {
val at = HashMap<String, androidx.compose.ui.geometry.Rect>()
/** The page about to open came from tile [key] (its art or icon for the flood), if it is on screen. */
fun mark(key: String, art: Any?, corner: Float) {
at[key]?.let { PageOrigin.mark(Origin(it, corner, art = art)) }
}
}
internal class Tile(
val title: String, val sub: String?, val art: File?, val key: String,
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
@@ -102,6 +114,7 @@ internal fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier)
.onGloballyPositioned { TileBounds.at[t.key] = it.boundsInRoot() }
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) }
}
}
@@ -76,6 +76,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -124,15 +126,30 @@ internal fun Pane(
val paneAt = remember { arrayOf(Offset.Zero) }
val livePage by rememberUpdatedState(s.pageKey)
// Picking another game changes the detail beside the list, not the whole page.
val target = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected
val railContent = if (selected.startsWith("app:")) "games" else selected
val target = if (page != null && s.pageKey != null) s.pageKey else railContent
// The game the Games page shows. It must outlive the selection: while the page animates out,
// drawing it with what is selected now (another rail item) turned it into a copy of that page.
val gameShown = remember { arrayOf("games") }
if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected
// What the pane showed before this change: the content a page opened over belongs to it.
val before = remember { arrayOf(target) }
if (page != null && s.pageKey != null) {
pages[s.pageKey] = page
if (s.pageKey !in origins) PageOrigin.take()?.let { origins[s.pageKey] = it.translate(-paneAt[0]) }
if (s.pageKey !in origins) PageOrigin.take()?.let { o ->
// A hop's page came out of its own rail item, and is that item's content.
origins[s.pageKey] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(railContent) else it }
}
} else if (target != before[0] && target !in origins) {
// A section's own page opened from a control in it (an emulator's card, a ROM's) floods
// out of that control and belongs to what was showing; a hop's comes out of its rail item.
PageOrigin.take()?.let { o -> origins[target] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(before[0]) else it } }
}
before[0] = target
val targetNow by rememberUpdatedState(target)
// A page leaving through a hop's link is not coming back to its control.
val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary
LaunchedEffect(target) { Hops.linking = false }
AnimatedContent(
targetState = target,
modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() },
@@ -143,9 +160,14 @@ internal fun Pane(
.togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f))
.apply { targetContentZIndex = 1f }
// Back out of it: the flood draws back into the control as the pane comes forward again.
initialState in origins -> (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
.togetherWith(ExitTransition.None)
.apply { targetContentZIndex = -1f }
initialState in origins && pageExit(origins[initialState]?.owner, targetState, leave) == PageExit.Drain ->
(fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
.togetherWith(ExitTransition.None)
.apply { targetContentZIndex = -1f }
// Away to something else: the control is not coming back, so the page sinks with the pane.
initialState in origins -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
.togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.95f))
.apply { targetContentZIndex = 1f }
else -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
.apply { targetContentZIndex = 1f }
@@ -153,26 +175,51 @@ internal fun Pane(
},
label = "pane",
) { key ->
val shown = if (page != null && key == s.pageKey) page else pages[key]
if (shown != null) {
DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } }
val drawn = (if (page != null && key == s.pageKey) page else pages[key])
?: { Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) }
// A hop leaving this content: it sinks back as the dot leaves it (and comes forward
// again, freshly drawn, when the hop is undone).
val hopSink by androidx.compose.animation.core.animateFloatAsState(
if (Hops.sinking == key) 1f else 0f, Motion.tw(260), label = "hopSink",
)
val shown: @Composable () -> Unit = {
Box(Modifier.fillMaxSize().graphicsLayer { val k = 1f - 0.05f * hopSink; scaleX = k; scaleY = k; alpha = 1f - 0.35f * hopSink }) { drawn() }
}
run {
DisposableEffect(key) { onDispose { if (key != livePage && key != targetNow) { pages.remove(key); origins.remove(key) } } }
val from = origins[key]
if (from == null) shown()
else {
// The control's own picture (a card's art, an emulator's icon) on the tile as it grows.
val art = when (val m = from.art) {
null -> null
is Int -> androidx.compose.ui.res.painterResource(m)
else -> coil.compose.rememberAsyncImagePainter(m)
}
val leaving = transition.targetState == EnterExitState.PostExit
// Settled as the page starts to leave, from what is coming in, not from where it came in.
val drains = remember(leaving) { !leaving || pageExit(from.owner, target, leave) == PageExit.Drain }
// Keeps a leaving page on screen while its flood draws back into the control.
transition.animateFloat(
transitionSpec = { if (targetState == EnterExitState.PostExit) Motion.tw(PAGE_RETURN_MS) else snap() },
transitionSpec = { if (targetState == EnterExitState.PostExit && drains) Motion.tw(PAGE_RETURN_MS) else snap() },
label = "pageReturn",
) { if (it == EnterExitState.PostExit) 1f else 0f }
PageFlood(from, leaving = transition.targetState == EnterExitState.PostExit) { shown() }
PageFlood(from, leaving = leaving, art = art, sinks = !drains) { shown() }
}
}
else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
}
}
}
}
/**
* An origin left of the pane (a rail item a hop flew to) starts half inside it, at the item's
* height: the page swells out of the pane's edge beside the item. Wholly outside, its tile was a
* bar that raced across from the far edge.
*/
internal fun inPane(o: Origin): Origin =
if (o.bounds.right > 0f) o else o.translate(Offset(-o.bounds.right + o.bounds.width / 2f, 0f))
@Composable
private fun Backdrop(art: File?) {
val colors = MaterialTheme.colorScheme
@@ -207,6 +254,7 @@ private fun Content(
val colors = MaterialTheme.colorScheme
val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp
val narrow = LocalNarrowPane.current
val density = androidx.compose.ui.platform.LocalDensity.current.density
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
// Setup scrolls inside itself, under its tabs.
@@ -226,12 +274,12 @@ private fun Content(
return
}
// The Store scrolls and navigates inside itself (its tabs and an app's page).
if (selected == "store" && s.storeEnabled) {
if (selected == "store") {
StorePage(s, a, modifier)
return
}
// Stores (GOG, Epic, Amazon) takes the whole pane: chips, shelves and a game's page inside it.
if (selected == "stores" && s.gameStoresEnabled) {
if (selected == "stores") {
StoresPage(s, a, modifier)
return
}
@@ -240,6 +288,11 @@ private fun Content(
GamesPage(s, a, selected, onSelect, modifier)
return
}
// Desktop is the Steam tab's shape: a hero with its lifted sheet, and shelves under it.
if (selected == "desktop") {
DesktopPage(s, a, modifier, onSelect)
return
}
// Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid
// instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view.
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) {
@@ -260,37 +313,6 @@ private fun Content(
})
}
}
selected == "desktop" -> {
val installed = s.emulators.filter { it.installed }
val available = s.emulators.filter { !it.installed }
val ctx = LocalContext.current
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
val apps = UserAppsState.items
var adding by rememberSaveable { mutableStateOf(false) }
Rise(0) {
PageHeader(stringResource(R.string.drawer_desktop)) {
if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false)
}
}
Rise(2) { DesktopCard(s, a) }
// Add closes the last grid: the installable emulators, or the installed ones once none are left.
val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) }
val addAfterReady = available.isEmpty()
if (ready.isNotEmpty() || addAfterReady) {
Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) }
Rise(4) {
LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
}
}
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() }
if (!addAfterReady) {
Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) }
Rise(6) {
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
}
}
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
}
selected.startsWith("user:") -> {
val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected }
if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect)
@@ -300,21 +322,26 @@ private fun Content(
if (e == null) Note(stringResource(R.string.content_not_installed)) else {
val pkgId = Library.packageId(e.id)
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
Rise(0) {
BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") }
}
// Out of its card on Desktop, with the card's icon; B draws it back in.
Rise(1) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
) {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp))
Image(painterResource(e.iconRes), null, modifier = Modifier.size(64.dp))
Column {
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(e.name, fontSize = if (narrow) 24.sp else 30.sp, fontWeight = FontWeight.Black, color = colors.onBackground)
Text(
systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
if (e.installed) Text(
listOfNotNull(
pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size).takeIf { e.id != "retroarch" || e.games.isNotEmpty() },
s.romsDir,
).joinToString(" · "),
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -339,7 +366,24 @@ private fun Content(
)
}
else Rise(5, Modifier.fillMaxWidth()) {
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
Column {
// RetroArch's games come from many systems: chips filter them, one
// selection gliding between them.
val systems = remember(e.games) { e.games.map { romSystem(it) }.distinct() }
var system by rememberSaveable(e.id) { mutableStateOf<String?>(null) }
if (systems.size > 1) Box(Modifier.padding(top = 10.dp)) {
val labels = listOf(stringResource(R.string.desktop_all_systems)) + systems
SheetChips(labels, if (system == null) 0 else systems.indexOf(system) + 1) { i -> system = if (i == 0) null else systems[i - 1] }
}
val shown = e.games.withIndex().filter { system == null || romSystem(it.value) == system }
ArtGrid(shown.map { (index, g) ->
val key = "rom:${e.id}:$index"
Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, key, e.iconRes) {
TileBounds.mark(key, g.art ?: e.iconRes, 12f * density)
onSelect(key)
}
}, wide = e.games.none { it.art != null })
}
}
} else {
if (pkg != null) Rise(2) {
@@ -381,12 +425,7 @@ private fun Content(
val pair = romFor(s, selected)
if (pair == null) Note(stringResource(R.string.content_rom_gone)) else {
val (e, g) = pair
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BackLink(e.name) { onSelect("emu:${e.id}") }
Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e)))
}
}
Rise(0) { Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) }
Rise(1) { Title(g.name) }
Rise(2) {
Row {
@@ -408,7 +447,10 @@ private fun Content(
Rise(4, Modifier.fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) {
TileBounds.mark("rom:${e.id}:$index", x.art ?: e.iconRes, 12f * density)
onSelect("rom:${e.id}:$index")
}
}, wide = others.none { it.art != null })
}
}
@@ -419,122 +461,6 @@ private fun Content(
}
}
/** The Linux desktop itself, above the emulators that run in it. */
@Composable
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var confirmRemove by rememberSaveable { mutableStateOf(false) }
if (confirmRemove) ConfirmDialog(
stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text),
stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false },
)
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
Cog(onClick = a.onDesktopSettings)
if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall),
enabled = !s.runtimeActionsBlocked) { confirmRemove = true }
BusyChip(s)
}
}
// The button goes under the title unless the card is wide enough for both side by side: in some
// languages its label alone takes half a small phone's pane.
BoxWithConstraints(Modifier.fillMaxWidth()) {
val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (!narrow) actions()
}
if (narrow) actions()
}
}
}
/** How wide the desktop card must be to keep its button beside the title. */
private val DesktopCardInlineWidth = 640.dp
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
private sealed class GridItem(val key: String) {
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
class User(val app: UserApps.App) : GridItem("user:${app.key}")
data object Add : GridItem("add")
}
/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */
@Composable
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
// A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the
// name no room at all.
val columns = when {
LocalNarrowPane.current -> 1
maxWidth < 720.dp -> 2
else -> 3
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
items.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, item ->
key(item.key) {
val m = Modifier.weight(1f).fillMaxHeight()
val isFirst = first && r == 0 && i == 0
when (item) {
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
GridItem.Add -> AddTile(m, isFirst, onAdd)
}
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
}
/** A grid tile's frame: the emulator tiles' look, focus glide and press. */
@Composable
private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale")
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:$id").then(if (isFirst) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (filled) colors.surface else Color.Transparent)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) { content() }
}
@Composable
private fun TileText(title: String, detail: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
Column(modifier = modifier) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
internal fun UserApps.Kind.label() = when (this) {
UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script
UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage
@@ -556,26 +482,6 @@ private fun UserAppIcon(app: UserApps.App, size: Int) {
}
}
@Composable
private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) =
TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) {
UserAppIcon(app, 44)
TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f))
}
/** The grid's last tile: opens the Add dialog. */
@Composable
private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val pal = LocalPalette.current
TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12),
) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) }
TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f))
}
}
/** The add or remove under way, or why the last one failed. */
@Composable
private fun UserAppsProgress() {
@@ -595,20 +501,17 @@ private fun UserAppsProgress() {
}
}
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (asked first). */
@Composable
private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
var confirm by remember(app.key) { mutableStateOf(false) }
var editing by rememberSaveable(app.key) { mutableStateOf(false) }
val x86 = (app.arch == "x86_64" || app.arch == "i386") && app.fex != com.droiddeck.launcher.runtime.LinuxFex.OFF
val fexReady by produceState<Boolean?>(null, app.key, x86, s.sessionRunning) {
value = if (x86) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.LinuxFex.ready(ctx) } else true
}
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } }
Rise(1) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -631,12 +534,8 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
BusyChip(s)
SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true }
if (app.repo != null) UpdateButton(app, s)
SecondaryButton(
stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove),
enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null,
) {
if (!confirm) confirm = true
else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") }
RemoveAppButton(app, enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null) {
UserAppsState.remove(ctx, app); onSelect("desktop")
}
if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false)
}
@@ -657,6 +556,31 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
}
}
/** Remove, which asks first: are you sure steps out of it in red, Keep first and focused. */
@Composable
private fun RemoveAppButton(app: UserApps.App, enabled: Boolean, onRemove: () -> Unit) {
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.user_apps_remove)
val title = stringResource(R.string.flathub_remove_title, app.name)
val keep = stringResource(R.string.flathub_keep)
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 2,
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(title)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(1)) {
StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onRemove() }
}
})
}
}
}
/** Looks up a newer release of an app added from GitHub, then installs it. */
@Composable
private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
@@ -688,21 +612,5 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
private fun systemLabel(e: Library.Emulator): String =
if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() }
@Composable
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val system = systemLabel(e)
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
Column(modifier = Modifier.weight(1f)) {
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (!e.installed) Text(
stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
/** The system a ROM belongs to: the folder it sits in under the ROMs folder. */
private fun romSystem(rom: Library.Rom): String = rom.hostPath.parentFile?.name.orEmpty()
@@ -77,6 +77,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.PhantomProcessLimit
// The front end's side rail: the page list and the resume entry under it.
@@ -103,27 +106,53 @@ internal fun SideRail(
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
// Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
val count = 7 + (if (s.isHomeApp) 1 else 0)
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
// A hop in flight moves the selection ahead of the page it is bringing.
val shown = Hops.railPreview ?: selected
val keys = buildList {
add("steam"); add("games")
add("stores")
add("desktop")
add("components")
if (s.isHomeApp) add("android-apps")
add("setup"); add("updates")
}
// Where each item sits in the column, for the selection that glides between them.
val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, androidx.compose.ui.geometry.Rect>() }
val tint = androidx.compose.runtime.rememberUpdatedState(LocalPalette.current.signal)
var railAt by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).onGloballyPositioned { railAt = it.boundsInRoot().topLeft }) {
// One selection for the whole rail, under the items: it glides from item to item as the
// chips' does, its leading edge first, and a jump of more than one item goes as a drop.
if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown]?.translate(-railAt), tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
modifier = Modifier.fillMaxWidth(),
) {
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
val place: (String) -> Modifier = { key ->
// In root px: an item that comes and goes sits in its own wrapper, not the column.
Modifier.onGloballyPositioned {
bounds[key] = it.boundsInRoot()
Hops.railBounds[key] = it.boundsInRoot()
}
}
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", shown == "steam", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Always there, so the items below it never move; an empty library says how to fill it.
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", shown == "games", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("games") }) { onSelect("games") }
// The game stores, right under Games: what they install lands there. Its badge counts
// the downloads in flight, so a download started here is never out of sight.
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// A section switched on in Setup comes in as the rail's own entrance; off, it folds away.
RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", shown == "desktop", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", shown == "components", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", shown == "android-apps", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", shown == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, place = place, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", shown == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, place = place, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
}
}
}
var lastRunning by remember { mutableStateOf("") }
@@ -142,6 +171,8 @@ private fun RailItem(
badge: Boolean = false,
/** A number on the item (downloads in flight); 0 shows nothing. */
count: Int = 0,
/** Reports where the item sits, for the rail's gliding selection and a hop's dot. */
place: (String) -> Modifier = { Modifier },
onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
@@ -162,8 +193,9 @@ private fun RailItem(
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
Motion.tw(220), label = "railFg",
)
// The selected look is the rail's glide under the item, so the item draws only hover here.
val fill by animateColorAsState(
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
if (current) Color.Transparent else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
Motion.tw(220), label = "railFill",
)
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
@@ -172,6 +204,9 @@ private fun RailItem(
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.testTag("rail-$key")
.then(place(key))
// Pops as a hop's dot lands on it.
.landingPop(Hops.railLanded[key] ?: 0)
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
.clip(Shape14)
@@ -70,6 +70,8 @@ import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
@@ -132,10 +134,6 @@ class FrontEndState(
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
val launcherFullscreen: Boolean = true,
val animationsEnabled: Boolean = true,
/** The Flathub Store, a beta the user turns on in Setup. */
val storeEnabled: Boolean = false,
/** The Stores section (GOG, Epic Games, Amazon Games), turned on in Setup. */
val gameStoresEnabled: Boolean = false,
/** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */
val gameStoresSpeedTier: String = "fast",
/** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */
@@ -178,8 +176,12 @@ class FrontEndActions(
val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {},
val onRom: (Library.Rom) -> Unit,
val onResume: () -> Unit,
/** Reads Steam's session settings for its lifted sheet (under Play). */
val onSteamSettings: () -> Unit,
/** Reads the desktop's session settings for its lifted sheet (under Open desktop). */
val onDesktopSettings: () -> Unit,
/** A lifted sheet's rows for one tab of [mode]'s settings, as last read. */
val modeSheet: @Composable (mode: String, tab: String, extras: SheetExtras) -> Unit = { _, _, _ -> },
val onRemoveDesktop: () -> Unit = {},
val onInstallPackage: (String) -> Unit,
val onRemovePackage: (String) -> Unit,
@@ -189,6 +191,8 @@ class FrontEndActions(
val onProtons: () -> Unit,
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
val onComponents: (focusContent: Boolean) -> Unit,
/** The Components page on one layer ([GPU_TAB], a component id): a hop's destination. */
val onComponentsTab: (String) -> Unit = {},
/** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */
val onSaveImport: (Library.SteamGame) -> Unit = {},
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
@@ -208,8 +212,6 @@ class FrontEndActions(
val onOrientation: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
val onGameStoresEnabled: (Boolean) -> Unit = {},
val onGameStoresSpeedTier: (String) -> Unit = {},
val onStoresOpenTab: (String) -> Unit = {},
val onNavigated: () -> Unit = {},
@@ -382,6 +384,18 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR
}
}
/**
* Asks [target] for focus on the next frames until a request lands (a control can take focus only
* once it is laid out), for at most half a second; then leaves focus alone, so a press right after
* is never pulled back.
*/
internal suspend fun focusSoon(target: () -> FocusRequester) {
repeat(30) {
androidx.compose.runtime.withFrameNanos { }
if (runCatching { target().requestFocus() }.isSuccess) return
}
}
@Composable
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) {
val frontFocus = remember { FrontFocus() }
@@ -423,19 +437,27 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
}
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() }
// Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link
// does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop.
// A page reached by a hop goes back the way it came; any other page just closes.
val pageBack = { if (!Hops.back(a.onPageBack)) a.onPageBack() }
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { pageBack() }
// Left any other way, the hop's page has no way back to keep.
LaunchedEffect(s.pageKey, selected) { Hops.forget(s.pageKey ?: selected) }
// A section reached by a hop (Desktop's Flathub card to the Store) goes back the way it came.
BackHandler(enabled = !processSettingsPageVisible && s.pageKey == null && Hops.origin?.page == selected) {
val rail = Hops.origin?.rail ?: return@BackHandler
Hops.back { selected = rail }
}
val density = androidx.compose.ui.platform.LocalDensity.current.density
remember(density) { Hops.density = density; true }
// Back (and B) from a ROM, an emulator, an added app or Flathub steps out one level instead
// of leaving the app: a ROM -> its emulator, the others -> Desktop.
BackHandler(
enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) &&
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")),
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"),
) {
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
// The Store turned off in Setup takes its page with it; so does Stores.
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
LaunchedEffect(s.gameStoresEnabled) { if (!s.gameStoresEnabled && selected == "stores") selected = "games" }
// The last game uninstalled leaves the Games tab on its empty state.
LaunchedEffect(s.steamGames.isEmpty()) {
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
@@ -443,11 +465,14 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
remember { Motion.refresh(ctx); true }
val railSelection = when {
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
s.pageKey?.startsWith("settings:steam") == true -> "steam"
s.pageKey?.startsWith("settings:") == true -> "desktop"
// A hop's page belongs to the rail item it flew to.
Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest
// Performance opens from Play's sheet, and is Steam's.
s.pageKey == "performance" -> "steam"
s.pageKey == "controller-mapping" -> "setup"
selected.startsWith("app:") -> "games"
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
// Flathub is a page of Desktop's, as an emulator's is.
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store" -> "desktop"
else -> s.pageKey ?: selected
}
// Bumped each time a rail item is picked, so a controller moves on into the new page.
@@ -458,6 +483,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
if (key == "components") a.onComponents(focusContent)
else {
if (s.pageKey != null) a.onPageBack()
// Another section: no way back to keep, and no sheet to put back.
Hops.origin = null
Hops.restore = null
Hops.sinking = null
selected = key
}
}
@@ -519,7 +548,22 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
val railFocus = Modifier
.focusProperties { enter = { frontFocus.railFor(railSelection) } }
.focusGroup()
Row(modifier = Modifier.fillMaxSize()) {
// Back from a hop onto a surface that is not a sheet (a game's Components card, Desktop's
// Flathub card): focus
// goes back to the control that asked, once the page has gone.
LaunchedEffect(Hops.restore) {
val r = Hops.restore ?: return@LaunchedEffect
if (r.surface == HOP_SURFACE_SHEET) return@LaunchedEffect
Hops.restore = null
if (r.rail != selected && !(r.rail == "games" && selected.startsWith("app:"))) selected = r.rail
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
frontFocus.last = r.control
focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() }
}
var screenAt by remember { mutableStateOf(Offset.Zero) }
CompositionLocalProvider(LocalShowSection provides { key -> selected = key }) {
Row(modifier = Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }) {
SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight())
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
@@ -535,6 +579,12 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
}
// Dots on their way somewhere: a hop to another section and back, a swap flying home.
FlightsLayer(screenAt)
// A confirm stepping out of the control that asked for it.
StepsLayer(screenAt)
appToChooseDisplay?.let { app ->
val secondaryDisplay = s.secondScreenDisplays.firstOrNull()
ChooseAppDisplayDialog(
@@ -597,8 +647,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
val hasBackTarget = (s.pageKey != null && page != null) ||
// A section reached by a hop goes back to where it came from, not to the rail.
(s.pageKey == null && Hops.origin?.page == selected) ||
((s.pageKey == null || page == null) &&
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")))
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"))
// At the top of a section, Back goes to the rail - the launcher itself is never backed out of.
BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) {
if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard)
@@ -71,6 +71,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.AppLanguage
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
@@ -166,21 +168,26 @@ internal fun SetupPanel(
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
var editSgdbKey by remember { mutableStateOf(false) }
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
// and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
// Four tabs by scope, a chip row with one gliding selection; LB and RB turn them from
// anywhere on the page. Build and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_diagnostics))
var tab by rememberSaveable { mutableStateOf(0) }
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
val chipFocus = remember { List(4) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
val inputModeManager = LocalInputModeManager.current
// The control a controller was on went with the old tab: it lands on the new tab itself.
// The control a controller was on went with the old tab: it lands on the new tab's chip.
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
runCatching { tabFocus[tab].requestFocus() }
runCatching { chipFocus[tab].requestFocus() }
}
}
val wide = !LocalNarrowPane.current
val runtimeOk = s.ready && !s.busy && !s.removalPending
val checks = listOf(gpuOk, runtimeOk, !limitBlocks, signedIn)
val passing = checks.count { it }
var showChecks by rememberSaveable { mutableStateOf(false) }
Rise(0, Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().bumpers(
@@ -189,15 +196,12 @@ internal fun SetupPanel(
),
) {
PageHeader(stringResource(R.string.setup_title)) {
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, pick) }
}
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
when (tab) {
0 -> {
SectionTitle(stringResource(R.string.setup_system_check), null)
// What Steam needs, one row each: green when done, one button when not. The
// process-limit controls only open under their row.
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
val gpuCheck: @Composable (Boolean) -> Unit = { divider ->
CheckRow(
if (gpuOk) CheckState.OK else CheckState.WARN,
when (gpu.support) {
@@ -210,7 +214,10 @@ internal fun SetupPanel(
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() })
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
},
divider = divider,
)
}
val runtimeCheck: @Composable (Boolean) -> Unit = { divider ->
CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
stringResource(R.string.setup_runtime),
@@ -221,7 +228,7 @@ internal fun SetupPanel(
s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed))
else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed))
},
divider = !s.busy,
divider = divider && !s.busy,
) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) }
if (s.busy) {
Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
@@ -232,8 +239,10 @@ internal fun SetupPanel(
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
val limitCheck: @Composable (Boolean) -> Unit = { divider ->
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
stringResource(R.string.setup_limit),
@@ -243,6 +252,7 @@ internal fun SetupPanel(
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus)
},
divider = divider && !showLimitDetails,
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
@@ -271,6 +281,9 @@ internal fun SetupPanel(
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
}
if (divider && showLimitDetails) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
val accountCheck: @Composable (Boolean) -> Unit = { divider ->
CheckRow(
if (signedIn) CheckState.OK else CheckState.WARN,
stringResource(R.string.setup_account),
@@ -281,11 +294,42 @@ internal fun SetupPanel(
else -> stringResource(R.string.setup_signed_in, it)
}
} ?: stringResource(R.string.setup_sign_in),
divider = false,
divider = divider,
)
}
SectionTitle(stringResource(R.string.setup_tools), null)
ToolGrid(s, a)
val all = listOf(gpuCheck, runtimeCheck, limitCheck, accountCheck)
// Everything passes: one line says so, and Details opens the checks in place.
ReadinessCard(
passing, checks.size,
listOfNotNull(
gpu.displayName(ctx),
s.installed?.let { stringResource(R.string.setup_ready_runtime, it) },
stringResource(R.string.setup_ready_limit_off).takeIf { !limitBlocks },
s.offlineAccount?.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.setup_ready_signed_in, it) },
).joinToString(" · "),
open = showChecks, onDetails = { showChecks = !showChecks },
) {
val shownChecks = all.filterIndexed { i, _ -> checks[i] }
shownChecks.forEachIndexed { i, check -> check(i < shownChecks.lastIndex) }
}
// Each check that warns is its own card, its Fix it as it always was.
if (passing < checks.size) {
SectionTitle(stringResource(R.string.setup_needs_you), null)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
all.forEachIndexed { i, check ->
if (!checks[i]) Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, AttentionAmber.copy(alpha = 0.5f), Shape14)) { check(false) }
}
}
}
SettingsGroup(stringResource(R.string.setup_account)) {
accountCheck(true)
ToggleRow(
host, "offline", stringResource(R.string.setup_offline),
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) }
}
1 -> {
val controller = s.controller
@@ -295,110 +339,79 @@ internal fun SetupPanel(
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
}
2 -> {
SettingsGroup(stringResource(R.string.setup_session)) {
ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(s, a, host)
val look: @Composable () -> Unit = {
SettingsGroup(stringResource(R.string.setup_group_look)) {
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
}
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
percent to stringResource(
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
else R.string.ctrl_percent, percent,
)
}, s.appScale, onPick = a.onAppScale,
)
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
s.animationsEnabled, onChange = a.onAnimationsEnabled)
LanguageRow(host, s.language, a.onLanguage)
}
}
val device: @Composable () -> Unit = {
SettingsGroup(stringResource(R.string.setup_group_device)) {
ToggleRow(
host, "home-screen", stringResource(R.string.setup_home),
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
if (s.homeScreenEnabled) {
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
}
ChoiceRow(
host, "orientation", stringResource(R.string.setup_orientation), null,
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
s.orientation, onPick = a.onOrientation,
)
ToggleRow(
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
s.launcherFullscreen,
) { a.onLauncherFullscreen(it) }
}
}
val art: @Composable () -> Unit = {
SettingsGroup(stringResource(R.string.setup_group_art)) {
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
ActionRow(
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
)
}
}
// Two columns on a wide page: how it looks; this device and the library's art.
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { look() }
Column(Modifier.weight(1f)) { device(); art() }
} else { look(); device(); art() }
}
3 -> {
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
ToggleRow(
host, "offline", stringResource(R.string.setup_offline),
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
}
3 -> {
SettingsGroup(stringResource(R.string.setup_launcher)) {
LanguageRow(host, s.language, a.onLanguage)
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
percent to stringResource(
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
else R.string.ctrl_percent, percent,
)
}, s.appScale, onPick = a.onAppScale,
)
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
}
ToggleRow(
host, "home-screen", stringResource(R.string.setup_home),
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
ChoiceRow(
host, "orientation", stringResource(R.string.setup_orientation), null,
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
s.orientation, onPick = a.onOrientation,
)
ToggleRow(
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
s.launcherFullscreen,
) { a.onLauncherFullscreen(it) }
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
s.animationsEnabled, onChange = a.onAnimationsEnabled)
if (s.homeScreenEnabled) {
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
}
}
SettingsGroup(stringResource(R.string.setup_linux_apps)) {
ToggleRow(
host, "store-enabled", stringResource(R.string.setup_store),
if (s.storeEnabled) stringResource(R.string.setup_store_on)
else stringResource(R.string.setup_store_off),
s.storeEnabled,
) { a.onStoreEnabled(it) }
}
SettingsGroup(stringResource(R.string.content_games)) {
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
ActionRow(
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
)
}
SettingsGroup(stringResource(R.string.setup_stores)) {
ToggleRow(
host, "stores-enabled", stringResource(R.string.setup_stores_show),
null, s.gameStoresEnabled,
) { a.onGameStoresEnabled(it) }
// The same rows as the Stores page's own cog, for whoever looks here first.
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
SegmentedTabs(
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
s.storesOpenTab,
) { a.onStoresOpenTab(it) }
}
ToggleRow(host, "stores-show-mature", stringResource(R.string.stores_show_mature), null, s.storesShowMature) { a.onStoresShowMature(it) }
SettingsRow(stringResource(R.string.setup_stores_speed), null) {
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
}
StoresEngineRow()
}
}
}
@@ -407,50 +420,43 @@ internal fun SetupPanel(
}
}
/** The launcher's own tools as cards: four across, two by two on a narrow page. */
/**
* The checks as one card while they all pass: a ring with how many pass, "Ready to play" and one
* line of what that means, and a Details chip that opens the checks themselves in place ([checks]).
*/
@Composable
private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
val columns = if (LocalNarrowPane.current) 2 else 4
val tools = listOf(
ToolSpec(Icons.Outlined.Folder, stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint), a.onFiles),
ToolSpec(Icons.Outlined.Extension, stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), a.onProtons),
ToolSpec(Icons.Outlined.Speed, stringResource(R.string.setup_tool_performance), stringResource(R.string.setup_tool_performance_hint), a.onPerformance),
ToolSpec(Icons.Outlined.VideogameAsset, stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint), a.onRoms),
)
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
for (row in tools.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit)
@Composable
private fun ToolCard(t: ToolSpec, modifier: Modifier) {
private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale")
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.paneItem("tool:${t.title}")
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
.controllerConfirm(onClick = t.onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
val ready = passing == total
Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
) {
val tint = if (ready) pal.good else AttentionAmber
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp)) {
androidx.compose.material3.CircularProgressIndicator(
progress = { passing / total.toFloat().coerceAtLeast(1f) }, strokeWidth = 4.dp,
color = tint, trackColor = tint.copy(alpha = 0.18f), modifier = Modifier.size(52.dp),
)
Text("$passing/$total", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
}
Column(Modifier.weight(1f)) {
Text(
stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
)
if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (passing > 0) SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true, onClick = onDetails)
}
AnimatedVisibility(open, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
Column(Modifier.fillMaxWidth()) {
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
checks()
}
}
}
}
@@ -479,7 +485,15 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
add { m ->
val back = stringResource(R.string.hop_from_games)
// Components is its own section: the card hops there, and B on it comes back here.
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
val at = Hops.sources["card:components"]
if (at == null) a.onComponents(true)
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
}
}
add { m ->
Box(m) {
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
@@ -57,6 +57,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
class PairRow(
@@ -119,155 +123,38 @@ class GpuDriversActions(
)
/**
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
* two lists each on their own, and imports, are under Advanced.
* Everything about the chip - SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the
* drivers in use - gathered once, off the main thread, with a button that copies it for a bug
* report. Stepped out of the Components page's device chip; [first] is where a pad starts.
*/
@Composable
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
internal fun DeviceDetails(first: FocusRequester) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var showAll by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
DeviceCard(s)
if (!s.unsupported) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) }
ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
}
if (s.auto) AutoCard(s)
else {
val shown = s.pairs.filter { it.suits || showAll }
if (shown.isEmpty()) Text(
if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed),
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
)
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
if (s.pairs.any { !it.suits }) FocusText(
if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal,
) { showAll = !showAll }
}
}
SettingsGroup(stringResource(R.string.gpu_advanced)) {
SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) {
DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") }
}
SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) {
DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") }
}
}
}
}
/**
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
*/
@Composable
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var open by remember { mutableStateOf(false) }
Box {
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
for (row in rows) DropdownMenuItem(
leadingIcon = { Radio(row.id == selected) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
)
HorizontalDivider(color = pal.line)
DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
}
}
}
/**
* This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip -
* SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once,
* off the main thread, with a button that copies it for a bug report.
*/
@Composable
private fun DeviceCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val tint = when {
s.unsupported -> pal.error
s.supported -> pal.good
else -> AttentionAmber
}
var open by rememberSaveable { mutableStateOf(false) }
var details by remember { mutableStateOf<List<DeviceInfo.Section>?>(null) }
var copied by remember { mutableStateOf(false) }
LaunchedEffect(open) {
if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) }
}
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val toggle = { open = !open; copied = false }
LaunchedEffect(Unit) { details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } }
Column(
Modifier.fillMaxWidth().clip(Shape14).background(colors.surface)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.glideBorder(hot, Shape14, pal.signal, pal.line),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).verticalScroll(rememberScrollState()),
) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().paneItem("gpu-device")
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle)
.padding(14.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
}
Column(Modifier.weight(1f)) {
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
// needs: a long one (in some languages) would squeeze the name to a letter a line.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
Icon(
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show),
tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp),
)
}
AnimatedVisibility(open) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) {
HorizontalDivider(color = pal.line)
val sections = details
if (sections == null) {
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(Modifier.fillMaxWidth())
} else {
for (section in sections) DeviceInfoSection(section)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
)
SecondaryButton(stringResource(R.string.device_info_copy), compact = true) {
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
copied = true
}
}
val sections = details
if (sections == null) {
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(Modifier.fillMaxWidth())
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
)
SecondaryButton(stringResource(R.string.device_info_copy), compact = true, modifier = Modifier.focusRequester(first)) {
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
copied = true
}
}
for (section in sections) DeviceInfoSection(section)
}
}
}
@@ -288,115 +175,9 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) {
Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
for ((label, value) in section.rows) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp))
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(150.dp))
Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f))
}
}
}
}
/** What Auto has set and what it is doing. */
@Composable
private fun AutoCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val active = s.activePair
val recommended = s.pairs.firstOrNull { it.recommended }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
) {
Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
Text(
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin),
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
)
Text(
if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair)
else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint),
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
if (s.busy != null) {
LinearProgressIndicator(
progress = { (s.percent.coerceAtLeast(0)) / 100f },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
)
}
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
}
}
@Composable
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
// Selection is the radio; the ring is only ever focus.
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
.clip(Shape14).background(colors.surface).background(fill)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
Column(Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Radio(selected)
}
}
@Composable
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val usable = enabled && p.complete && !p.active
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
.clip(Shape12).background(colors.surface).background(fill)
.glideBorder(hot, Shape12, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
.controllerConfirm(enabled = usable, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Radio(p.active)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (p.recommended) Text(
stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
when {
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
}
p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
!p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant)
p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
}
@Composable
private fun Radio(selected: Boolean) {
val pal = LocalPalette.current
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
}
}
@@ -0,0 +1,174 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
// A link from a control in one section to a page in another (the Play sheet's Graphics drivers,
// a game's Components card): the control squashes into a dot that flies to the destination's rail
// item, the rail's selection glides there as it passes the top of its arc, and the page floods
// out of the rail item once it lands. B on that page flies the dot back and puts the source
// surface back as it was, focus on the control that asked.
/**
* Where a hop came from: the rail item that was showing ([rail]), the surface on it (a sheet, the
* Games page) and its tab, the control's key and its root bounds, and the words for the way back.
* [page] is the page key the hop opened, so leaving it any other way forgets the way back.
*/
internal class HopOrigin(
val rail: String,
val surface: String,
val tab: String?,
val control: String,
val bounds: Rect,
val back: String,
val dest: String,
val page: String,
)
/** The surfaces a hop can start from and come back to. */
internal const val HOP_SURFACE_GAMES = "games"
internal const val HOP_SURFACE_DESKTOP = "desktop"
internal const val HOP_SURFACE_SHEET = "sheet"
internal object Hops {
/** The hop the page on screen was reached by, while B can still undo it. */
var origin by mutableStateOf<HopOrigin?>(null)
/** The pane content a hop is leaving (a rail key): it sinks back as the dot leaves it. */
var sinking by mutableStateOf<String?>(null)
/** Set while a hop's dot is flying: the rail item its selection has already glided to. */
var railPreview by mutableStateOf<String?>(null)
/**
* Set as a page leaves through a hop (either way), so the pane sinks it rather than draining
* it; the pane clears it once the change has started.
*/
var linking by mutableStateOf(false)
/** A surface to put back as it was (a sheet re-lifted on its tab), once, and the control to focus. */
var restore by mutableStateOf<HopOrigin?>(null)
/** Per rail item, bumped when a dot lands on it: the item pops. */
val railLanded = mutableStateMapOf<String, Int>()
/** Bumped when a hop's page has flooded in: its target (a layer card) sends out one ring. */
var arrived by mutableIntStateOf(0)
/** Where each rail item sits, root px. */
val railBounds = HashMap<String, Rect>()
/** Where each control that can start a hop sits, root px. */
val sources = HashMap<String, Rect>()
/**
* Hops from the control at [origin].bounds to [origin].dest: [go] opens the page there once the dot lands.
* With animations off there is no dot and no flood, only the page.
*/
fun go(origin: HopOrigin, go: () -> Unit) {
val railAt = railBounds[origin.dest]
val finish: () -> Unit = {
railPreview = null
this.origin = origin
if (railAt != null && Motion.scale != 0f) PageOrigin.mark(Origin(railAt, 14f * density, owner = origin.dest))
linking = true
go()
railLanded[origin.dest] = (railLanded[origin.dest] ?: 0) + 1
arrived++
}
if (railAt == null || Motion.scale == 0f) { finish(); return }
// The surface the control is on sinks as its dot leaves, and stays down as it goes.
sinking = origin.rail
Flights.fly(Flight(
origin.bounds, to = { railAt.center },
onHalf = { railPreview = origin.dest },
onLand = finish,
))
}
/**
* B on a page reached by a hop: the page sinks, the source surface comes back as it was, and
* the dot flies home from the rail item to the control. False when there is no hop to undo.
*/
fun back(close: () -> Unit): Boolean {
val o = origin ?: return false
origin = null
sinking = null
val railAt = railBounds[o.dest]
railPreview = if (railAt != null && Motion.scale != 0f) o.dest else null
restore = o
linking = true
close()
if (railAt == null || Motion.scale == 0f) { railPreview = null; return true }
Flights.fly(Flight(
railAt, to = { (sources[o.control] ?: o.bounds).center },
onHalf = { railPreview = null },
))
return true
}
/** The page the hop opened has gone some other way: there is no way back to keep. */
fun forget(pageKey: String?) {
val o = origin ?: return
if (pageKey != o.page) { origin = null; sinking = null }
}
/** Screen density, for the rail item's corner; set by the front end. */
var density = 1f
}
/** Shows a rail section directly (no page closing, nothing forgotten): what a hop's way back calls. */
internal val LocalShowSection = androidx.compose.runtime.staticCompositionLocalOf<(String) -> Unit> { {} }
/** Remembers where this control sits, so a hop from it can fly from it and back to it. */
internal fun Modifier.hopSource(key: String): Modifier = onGloballyPositioned { Hops.sources[key] = it.boundsInRoot() }
/**
* "B · back to <source>" at the right of a page's header while it was reached by a hop; a tap is B.
* Nothing when it was not.
*/
@Composable
internal fun HopBackChip(onBack: () -> Unit) {
val o = Hops.origin ?: return
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
Text(
stringResource(R.string.hop_back, o.back), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape)
.background(pal.signal.copy(alpha = if (hot) 0.16f else 0.08f))
.glideBorder(hot, shape, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onBack)
.controllerConfirm(onClick = onBack)
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
@@ -0,0 +1,805 @@
package com.droiddeck.launcher.ui
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.zIndex
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.animation.core.animateFloat
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import com.droiddeck.launcher.R
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// Play's settings, lifted out from under Play instead of opened on a page of their own. At rest a
// lip tucks out under Play and says what it will do. Down (or a pull up, or a tap on the lip)
// stretches the lip, the wall sinks back, and a sheet rises from the pane's foot with Play riding
// its top edge as the handle - StepOut's move, with the pane for a box. B, Up from the chips or a
// tap above folds it away; A on Play launches straight from it, the sheet folding into the pill
// before the launch flood. Open desktop does the same with the desktop's settings.
/** The sheets' tabs (their chips), by id. */
internal object SheetTab {
const val DISPLAY = "display"
const val CONTROLS = "controls"
const val AUDIO = "audio"
const val SESSION = "session"
const val LIBRARY = "library"
const val STEAM = "steam"
const val FILES = "files"
}
/**
* Where the open sheet's top edge sits in a pane [paneH] tall (px), for a button [buttonH] tall
* riding it: 82dp down a 360dp pane, the same share of any other, and never so high that the
* button's top comes closer than [minAbove] to the pane's. [ride] is how far below the edge the
* button's centre sits.
*/
internal fun sheetTop(paneH: Float, buttonH: Float, minAbove: Float, ride: Float): Float =
maxOf(paneH * 82f / 360f, minAbove + buttonH / 2f - ride)
/** A finger's hold on the sheet: where it started (0 rest, 1 open), where it is, whether it has it. */
private class SheetDrag {
var from = 0f
var progress = 0f
var engaged = false
}
/** One piece of the sheet's rows fades and rises in on its own beat (a settings group asks for its index). */
internal class SheetBeat(private val pieces: List<Animatable<Float, AnimationVector1D>>) {
private var next = 1
fun take(): Int = next++
fun value(i: Int): Float = pieces[i.coerceIn(0, pieces.lastIndex)].value
}
/** The sheet whose rows are being laid out, so each settings group can take its beat. */
internal val LocalSheetBeat = staticCompositionLocalOf<SheetBeat?> { null }
/** A settings group's piece of the sheet's entrance; nothing outside a sheet. */
@Composable
internal fun Modifier.sheetPiece(): Modifier {
val beat = LocalSheetBeat.current ?: return this
val i = remember { beat.take() }
return graphicsLayer {
val v = beat.value(i)
alpha = v
translationY = (1f - v) * 6.dp.toPx()
}
}
private val SheetInset = 12.dp
private val SheetCorner = 18.dp
private val ChipsTop = 14.dp
private val RowsTop = 54.dp
private const val PIECES = 12
/**
* A hero with one big button and the sheet that lifts out from under it. [key] names the sheet
* (a hop comes back to it, the lip remembers what it said); [tabs] are its chips (id to label) and
* [rows] what each holds. [background] sinks and [words] fade as it opens. [button] draws the
* button itself with the click it should make; [onPrimary] is what that click does once the
* sheet is out of the way. [onOpen] reads the settings the rows show.
*/
@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class)
@Composable
internal fun LiftHero(
key: String,
tabs: List<Pair<String, String>>,
onOpen: () -> Unit,
onPrimary: () -> Unit,
background: @Composable () -> Unit,
/**
* Drawn over [background] at the pane's own size: the wall's fades, which must not sink with
* it. Given how far the sheet is up (0 to 1), for anything there that should give way to it.
*/
scrim: @Composable (lifted: () -> Float) -> Unit = {},
words: @Composable () -> Unit,
/**
* Buttons beside the big one (Desktop's Flathub and +). They stay where they are and fade as
* the sheet lifts: only the big button rides it.
*/
beside: @Composable () -> Unit = {},
button: @Composable (onClick: () -> Unit) -> Unit,
rows: @Composable (tab: String) -> Unit,
modifier: Modifier = Modifier,
/**
* Down on the button lifts the sheet (Play). False: Down goes to the lip, which A lifts, and Down
* from the lip goes on past it to what is under the hero (Desktop's shelves).
*/
downOpens: Boolean = true,
/** Told when the sheet starts to lift (true) and once it has gone (false). */
onOpenChange: (Boolean) -> Unit = {},
start: Dp = 44.dp,
bottom: Dp = 44.dp,
) {
val density = LocalDensity.current
val inputMode = LocalInputModeManager.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val ff = LocalFrontFocus.current
val narrow = LocalNarrowPane.current
// Held as states too, so the sheet's focus rule reads what they are now, not at the last composition.
val openState = remember { mutableStateOf(false) }
var open by openState
var shown by remember { mutableStateOf(false) }
val launchingState = remember { mutableStateOf(false) }
var launching by launchingState
var tab by remember { mutableStateOf(0) }
var travel by remember { mutableStateOf(1) }
// The control to focus once a sheet put back by a hop's B has lifted, instead of the first chip.
var focusOnLift by remember { mutableStateOf<String?>(null) }
// Set as the sheet closes and Play lands: the lip may come back before the sheet has quite gone.
var lipBack by remember { mutableStateOf(false) }
// Set as a finger's drag lets go: the sheet is already where it is going.
var dragSettled by remember { mutableStateOf(false) }
val scope = androidx.compose.runtime.rememberCoroutineScope()
val onOpenNow = rememberUpdatedState(onOpen)
val pull = remember { Animatable(0f) }
val sink = remember { Animatable(0f) }
val lift = remember { Animatable(0f) }
val ride = remember { Animatable(0f) }
val hot = remember { Animatable(1f) }
val lipShow = remember { Animatable(0f) }
val lipGone = remember { Animatable(0f) }
val fold = remember { Animatable(0f) }
val foldTop = remember { Animatable(0f) }
val pieces = remember { List(PIECES) { Animatable(0f) } }
val chipFocus = remember(tabs.size) { List(tabs.size) { FocusRequester() } }
val playSrc = remember { MutableInteractionSource() }
val playHovered by playSrc.collectIsHoveredAsState()
var playFocused by remember { mutableStateOf(false) }
var heroAt by remember { mutableStateOf(Offset.Zero) }
var rest by remember { mutableStateOf<Rect?>(null) }
fun focusPlay() { if (ff != null && inputMode.inputMode == InputMode.Keyboard) runCatching { ff.primary.requestFocus() } }
fun close() {
if (!open) return
// Focus leaves the rows before they go; the sheet lets it go once it is closing.
open = false
focusPlay()
}
// Back from a hop the sheet started (or a page it opened): put it back as it was.
LaunchedEffect(Unit) {
val r = Hops.restore ?: return@LaunchedEffect
if (r.surface != HOP_SURFACE_SHEET || r.rail != key) return@LaunchedEffect
Hops.restore = null
tab = tabs.indexOfFirst { it.first == r.tab }.coerceAtLeast(0)
focusOnLift = r.control
open = true
}
// Launched: the session covers the screen, and the sheet is put away under it.
val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = androidx.lifecycle.LifecycleEventObserver { _, e ->
if (e == androidx.lifecycle.Lifecycle.Event.ON_STOP && (open || shown)) {
open = false; shown = false; launching = false
}
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
LaunchedEffect(open, launching) {
if (dragSettled) {
// A finger brought it here and has already animated it: only what opening needs.
dragSettled = false
if (open) {
if (inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { chipFocus[tab].requestFocus() } }
} else focusPlay()
return@LaunchedEffect
}
if (open && !launching) {
lipBack = false
shown = true
onOpen()
fold.snapTo(0f); foldTop.snapTo(0f)
coroutineScope {
// Pull: the lip stretches down as the wall sinks back and dims.
launch { pull.animateTo(24f, Motion.sp(0.7f, 380f)) }
launch { sink.animateTo(1f, Motion.tw(260)) }
// Lift: the sheet rises with Play riding its top edge; the lip lets go.
launch {
delay(Motion.ms(120).toLong())
launch { lipGone.animateTo(1f, Motion.tw(140)) }
launch { pull.animateTo(0f, Motion.sp(1f, 500f)) }
launch { lift.animateTo(1f, Motion.sp(0.7f, 380f)) }
launch { ride.animateTo(1f, Motion.sp(0.7f, 380f)) }
launch { hot.snapTo(1f); delay(Motion.ms(100).toLong()); hot.animateTo(0f, Motion.tw(200)) }
}
// Each piece on its beat from the Lift, as the reel has it: 260ms + 55ms a piece after it starts.
pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } }
if (inputMode.inputMode == InputMode.Keyboard) launch {
val control = focusOnLift
focusOnLift = null
delay(Motion.ms(if (control != null) 420 else 300).toLong())
withFrameNanos { }
val target = control?.let { ff?.items?.get(it) } ?: chipFocus[tab]
runCatching { target.requestFocus() }
}
}
} else if (launching) {
// Play from the sheet: the rows go, the sheet folds into the pill, then the launch.
coroutineScope {
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); fold.animateTo(1f, Motion.sp(1f, 420f)) }
launch { delay(Motion.ms(160).toLong()); foldTop.animateTo(1f, Motion.sp(1f, 380f)) }
}
shown = false
onPrimary()
// The session covers the screen and the sheet is put away under it (ON_STOP). If
// nothing started, put it away anyway.
delay(3_000)
launching = false
} else if (shown) {
coroutineScope {
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
launch { delay(Motion.ms(60).toLong()); lift.animateTo(0f, Motion.sp(1f, 380f)) }
launch { delay(Motion.ms(100).toLong()); ride.animateTo(0f, Motion.sp(0.55f, 300f)) }
launch { sink.animateTo(0f, Motion.tw(300)) }
// The lip tucks back under Play as it lands, not once every spring has settled.
launch { delay(Motion.ms(420).toLong()); lipGone.snapTo(0f); lipBack = true }
}
shown = false
lipBack = false
lipGone.snapTo(0f)
hot.snapTo(1f)
} else {
// Snapped shut under a session: everything back at rest, no animation.
listOf(pull, sink, lift, ride, lipGone, fold, foldTop).forEach { it.snapTo(0f) }
pieces.forEach { it.snapTo(0f) }
hot.snapTo(1f)
}
}
BackHandler(enabled = open && !launching) { close() }
val openNow = open || shown
LaunchedEffect(openNow) { onOpenChange(openNow) }
// The lip: there while Play has a pad or a pointer on it, or always with touch alone.
val touchOnly = inputMode.inputMode == InputMode.Touch
val lipFocus = remember { FocusRequester() }
val settingsLabel = stringResource(R.string.drawer_page_settings)
var lipFocused by remember { mutableStateOf(false) }
val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused)
LaunchedEffect(lipWanted) {
if (lipWanted) {
// Back from the sheet it comes straight away; otherwise a beat after Play has focus.
if (!lipBack) delay(Motion.ms(160).toLong())
lipShow.animateTo(1f, Motion.tw(220))
} else {
delay(Motion.ms(140).toLong())
lipShow.animateTo(0f, Motion.tw(220))
}
}
// How far the sheet travels, for a finger's drag to follow: set once the hero is measured.
val travelPx = remember { floatArrayOf(1f) }
val drag = remember { SheetDrag() }
/** The sheet [p] of the way up (0 at rest, 1 open) under a finger. */
fun follow(p: Float) {
drag.progress = p
scope.launch {
lift.snapTo(p); ride.snapTo(p); sink.snapTo(p.coerceIn(0f, 1f)); hot.snapTo(0f)
lipGone.snapTo(1f)
pieces.forEach { it.snapTo(p.coerceIn(0f, 1f)) }
}
}
/** A finger takes the sheet where it is: up from rest, or down from open. */
fun beginDrag() {
drag.engaged = true
drag.from = if (open) 1f else 0f
drag.progress = drag.from
if (!open) {
shown = true
scope.launch { fold.snapTo(0f); foldTop.snapTo(0f) }
onOpenNow.value()
}
}
/**
* The finger lets go with [v] px/s (up negative): a flick goes the way it was flicked; a slow
* drag stays once it is a third of the way from where it started.
*/
fun settleDrag(v: Float) {
drag.engaged = false
val flick = with(density) { 600.dp.toPx() }
val opens = when {
v < -flick -> true
v > flick -> false
drag.from > 0.5f -> drag.progress > 0.7f
else -> drag.progress > 0.3f
}
dragSettled = true
scope.launch {
val to = if (opens) 1f else 0f
coroutineScope {
launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) }
launch { ride.animateTo(to, Motion.sp(if (opens) 0.7f else 0.55f, if (opens) 380f else 300f)) }
launch { sink.animateTo(to, Motion.tw(220)) }
pieces.forEachIndexed { i, piece -> launch { if (opens) delay(Motion.ms(i * 35).toLong()); piece.animateTo(to, Motion.tw(if (opens) 200 else 90)) } }
}
if (!opens) { shown = false; lipGone.snapTo(0f); hot.snapTo(1f); lipBack = false }
}
if (open != opens) open = opens else dragSettled = false
}
// Pulled down past the top of the rows, the sheet itself comes down with the finger; up again
// before letting go, it goes back up before the rows scroll.
val rowsPull = remember {
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset {
if (!drag.engaged || available.y >= 0f) return Offset.Zero
val before = drag.progress
follow((before - available.y / travelPx[0]).coerceAtMost(1f))
return Offset(0f, (drag.progress - before) * -travelPx[0])
}
override fun onPostScroll(consumed: Offset, available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset {
if (available.y <= 0f || source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag || !openState.value || launchingState.value) return Offset.Zero
if (!drag.engaged) beginDrag()
follow((drag.progress - available.y / travelPx[0]).coerceIn(0f, 1f))
return available
}
override suspend fun onPreFling(available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity {
if (!drag.engaged) return androidx.compose.ui.unit.Velocity.Zero
settleDrag(available.y)
return available
}
}
}
BoxWithConstraints(
modifier.fillMaxSize().onGloballyPositioned { heroAt = it.boundsInRoot().topLeft }
// A swipe anywhere on the hero moves the sheet under the finger - up from rest, down
// when open - and lets go open or shut by how far it went and how fast. A swipe that
// starts the wrong way (down at rest) is left alone, for the page under it.
.pointerInput(Unit) {
val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
var decided = false
detectVerticalDragGestures(
onDragStart = { tracker.resetTracking(); decided = false; drag.engaged = false },
onDragEnd = { if (drag.engaged) settleDrag(tracker.calculateVelocity().y) },
onDragCancel = { if (drag.engaged) settleDrag(0f) },
) { change, amount ->
if (launching) return@detectVerticalDragGestures
if (!decided) {
decided = true
// Up at rest opens; down when open closes. Anything else is not for the sheet.
if (if (open) amount > 0f else amount < 0f) beginDrag()
}
if (!drag.engaged) return@detectVerticalDragGestures
change.consume()
tracker.addPosition(change.uptimeMillis, change.position)
follow((drag.progress - amount / travelPx[0]).coerceIn(0f, 1.04f))
}
},
) {
val w = constraints.maxWidth.toFloat()
val h = constraints.maxHeight.toFloat()
val inset = with(density) { SheetInset.toPx() }
val restRect = rest?.translate(-heroAt)
val buttonH = restRect?.height ?: with(density) { 56.dp.toPx() }
val openTop = sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, with(density) { 3.dp.toPx() })
// Play's raised place: its centre 3dp under the sheet's edge, same left inset.
val raise = restRect?.let { (openTop + with(density) { 3.dp.toPx() } - buttonH / 2f) - it.top } ?: 0f
travelPx[0] = (h - openTop).coerceAtLeast(1f)
// The wall behind sinks back about the pane's centre.
val foot = h
Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() }
// The fades stay put: scaled with the wall they stopped short of the pane's edges and left
// a bright band of unfaded wall down the side and along the top.
scrim { sink.value }
// The dim; a tap on it (above the sheet) folds the sheet away.
if (shown || open) Box(
Modifier.fillMaxSize()
.graphicsLayer { alpha = sink.value }.background(Color.Black.copy(alpha = 0.42f))
.focusProperties { canFocus = false }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open && !launching) { close() },
)
// The sheet: inset at the sides, rounded at the top, no bottom edge - the pane's foot is its foot.
if (shown) {
val top = lerp(foot, openTop, lift.value)
val sheetLeft = inset
val sheetRight = w - inset
val path = remember { Path() }
val rimPath = remember { Path() }
val corner = with(density) { SheetCorner.toPx() }
val rim = with(density) { 1.4.dp.toPx() }
val pill = restRect?.translate(0f, raise)
Canvas(Modifier.fillMaxSize()) {
val f = fold.value
val ft = foldTop.value
val l = if (pill != null) lerp(sheetLeft, pill.left, f) else sheetLeft
val r = if (pill != null) lerp(sheetRight, pill.right, f) else sheetRight
val t = if (pill != null) lerp(top, pill.top, ft) else top
val b = if (pill != null) lerp(foot + corner, pill.bottom, ft) else foot + corner
val c = if (pill != null) lerp(corner, with(density) { 12.dp.toPx() }, ft) else corner
val bc = if (ft > 0f) c else 0f
path.reset()
path.addRoundRect(RoundRect(l, t, r, b, CornerRadius(c), CornerRadius(c), CornerRadius(bc), CornerRadius(bc)))
val fill = lerp(colors.surface, pal.signal, 0.22f * hot.value)
drawPath(path, fill)
val rimColor = pal.signal.copy(alpha = lerp(0.55f, 0.9f, hot.value))
if (ft > 0f) drawPath(path, rimColor, style = Stroke(rim))
else {
// Sides and top only: the sheet runs on under the pane's foot.
rimPath.reset()
rimPath.moveTo(l, b)
rimPath.lineTo(l, t + c)
rimPath.arcTo(Rect(l, t, l + 2 * c, t + 2 * c), 180f, 90f, false)
rimPath.lineTo(r - c, t)
rimPath.arcTo(Rect(r - 2 * c, t, r, t + 2 * c), -90f, 90f, false)
rimPath.lineTo(r, b)
drawPath(rimPath, rimColor, style = Stroke(rim))
}
}
// What the sheet holds: the chips to the right of Play, the rows under them.
val chipsX = with(density) {
if (narrow || pill == null) 16.dp.toPx() else pill.right + 20.dp.toPx() - sheetLeft
}
val chipsY = with(density) { if (narrow && pill != null) (pill.bottom - openTop) + 10.dp.toPx() else ChipsTop.toPx() }
// The rows start under the chips, which are 44dp to touch: from 54dp they ran under the
// chips' feet and, scrolled, covered them.
val rowsY = with(density) { maxOf(RowsTop.toPx(), chipsY + 52.dp.toPx()) }
val sheetW = sheetRight - sheetLeft
Box(
Modifier
.offset { IntOffset(sheetLeft.roundToInt(), top.roundToInt()) }
.size(with(density) { sheetW.toDp() }, with(density) { (foot - openTop).coerceAtLeast(0f).toDp() })
// Touches on the sheet stay on it; none reach the dim behind.
.pointerInput(Unit) { detectTapGestures { } }
.controllerBack { close() }
.bumpers(
// LB / RB: focus goes to the new tab's chip, so a pad on a row (which goes with
// its tab) is never left with nothing.
onPrevious = { if (tab > 0) { travel = -1; tab--; runCatching { chipFocus[tab].requestFocus() } } },
onNext = { if (tab < tabs.lastIndex) { travel = 1; tab++; runCatching { chipFocus[tab].requestFocus() } } },
)
// A pad stays in the sheet until it folds; Left from it reaches Play, the handle.
.focusProperties { exit = { dir -> if (!openState.value || launchingState.value) FocusRequester.Default else if (dir == FocusDirection.Left && ff != null) ff.primary else FocusRequester.Cancel } }
.focusGroup(),
) {
Box(
Modifier
.offset { IntOffset(chipsX.roundToInt(), chipsY.roundToInt()) }
// Over the rows, whatever they scroll under them.
.zIndex(1f)
.width(with(density) { (sheetW - chipsX - 16.dp.toPx()).coerceAtLeast(0f).toDp() })
.graphicsLayer { val v = pieces[0].value; alpha = v; translationY = (1f - v) * 6.dp.toPx() }
// Up from the chips folds the sheet (a finger drags it down, see the hero's drag).
.onPreviewKeyEvent { e ->
val k = e.nativeKeyEvent
if (k.keyCode == KeyEvent.KEYCODE_DPAD_UP) { if (k.action == KeyEvent.ACTION_DOWN) close(); true } else false
},
) {
SheetChips(tabs.map { it.second }, tab, chipFocus) { i -> travel = if (i > tab) 1 else -1; tab = i }
}
Box(
Modifier
.offset { IntOffset(0, rowsY.roundToInt()) }
.nestedScroll(rowsPull)
.fillMaxWidth()
.height(with(density) { (foot - openTop - rowsY).coerceAtLeast(0f).toDp() }),
) {
AnimatedContent(
targetState = tab,
transitionSpec = {
val d = travel
val shift = with(density) { 24.dp.roundToPx() }
(slideInHorizontally(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240)))
.togetherWith(slideOutHorizontally(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240)))
},
label = "sheetTab",
) { t ->
val leaving = transition.targetState == androidx.compose.animation.EnterExitState.PostExit
val leavingNow = rememberUpdatedState(leaving)
CompositionLocalProvider(LocalSheetBeat provides remember { SheetBeat(pieces) }) {
Column(
Modifier.fillMaxSize()
// Focus never lands in rows on their way out. Same group always, only
// its rule changes (canFocus here made the scroll's own group a stop).
.focusProperties { enter = { if (leavingNow.value) FocusRequester.Cancel else FocusRequester.Default } }
.focusGroup()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
) { rows(tabs[t].first) }
}
}
}
}
}
// The words and the button, where they always were; the button rides the sheet's edge.
Column(Modifier.align(Alignment.BottomStart).padding(start = start, bottom = bottom, end = 20.dp)) {
Box(Modifier.graphicsLayer { alpha = 1f - sink.value }) { words() }
Spacer(Modifier.height(18.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier
.onGloballyPositioned { rest = it.boundsInRoot() }
.graphicsLayer { translationY = raise * ride.value }
.onFocusChanged { playFocused = it.hasFocus }
.hoverable(playSrc)
.onPreviewKeyEvent { e ->
val k = e.nativeKeyEvent
if (k.keyCode != KeyEvent.KEYCODE_DPAD_DOWN) false
else {
if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) {
if (open) runCatching { chipFocus[tab].requestFocus() }
else if (downOpens) open = true
else runCatching { lipFocus.requestFocus() }
}
true
}
},
) {
button {
if (launching) return@button
if (open || shown) { launching = true; open = false } else onPrimary()
}
}
// Gone with the words while the sheet is up; nothing a pad or a finger can reach then.
val besideShown = !(open || shown)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.graphicsLayer { alpha = 1f - sink.value }
.focusProperties { canFocus = besideShown },
) { if (besideShown || sink.value < 0.99f) beside() }
}
}
// The lip, under Play: stretched by the pull, gone as the sheet lifts.
val r0 = restRect
// A lip a pad can reach stays composed (unseen at rest) so Down always has it to land on.
if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f || (!downOpens && !open && !shown))) {
var handleW by remember { mutableStateOf(0f) }
val lipH = 22.dp + pull.value.dp
val lift = { if (!open) open = true }
Box(
Modifier
.offset { IntOffset((r0.center.x - handleW / 2f).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) }
.onSizeChanged { handleW = it.width.toFloat() }
.graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) }
// 44dp to touch; the tab itself is the top 22.
.heightIn(min = 44.dp)
.semantics { contentDescription = settingsLabel }
.then(
if (downOpens) Modifier.focusProperties { canFocus = false }
else Modifier.focusRequester(lipFocus).onFocusChanged { lipFocused = it.isFocused }.controllerConfirm(onClick = lift),
)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = lift),
) {
Box(Modifier.glideBorder(lipFocused, RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp), LocalPalette.current.signal)) {
Handle(lipH)
}
}
}
}
}
/**
* The sheet's handle, tucked under Play: "Settings" and a chevron that bobs up every so often,
* saying there is a drawer to pull up. Still with animations off.
*/
@Composable
private fun Handle(height: Dp) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
val still = Motion.scale == 0f
val bob = androidx.compose.animation.core.rememberInfiniteTransition(label = "handle")
// A quick lift and settle, then a rest: a nudge, not a pulse.
val lift by bob.animateFloat(
0f, 0f,
androidx.compose.animation.core.infiniteRepeatable(
androidx.compose.animation.core.keyframes {
durationMillis = 2400
0f at 0
1f at 180 using androidx.compose.animation.core.FastOutSlowInEasing
-0.25f at 420
0f at 620
},
),
label = "handleBob",
)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.height(height).clip(shape).background(colors.surface)
.drawBorderSidesAndFoot(pal.line, 12.dp)
.padding(start = 14.dp, end = 10.dp),
) {
Text(stringResource(R.string.drawer_page_settings), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1)
Icon(
Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant,
modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else -3.dp.toPx() * lift },
)
}
}
/** A 1dp outline down the sides and along the foot, none along the top (the lip tucks under Play). */
private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp): Modifier =
this.drawWithContent {
drawContent()
val c = corner.toPx()
val sw = 1.dp.toPx()
val p = Path().apply {
moveTo(sw / 2, 0f)
lineTo(sw / 2, size.height - c)
arcTo(Rect(sw / 2, size.height - 2 * c + sw / 2, 2 * c, size.height - sw / 2), 180f, -90f, false)
lineTo(size.width - c, size.height - sw / 2)
arcTo(Rect(size.width - 2 * c, size.height - 2 * c + sw / 2, size.width - sw / 2, size.height - sw / 2), 90f, -90f, false)
lineTo(size.width - sw / 2, 0f)
}
drawPath(p, color, style = Stroke(sw))
}
/**
* The sheet's chips: equal capsules with one selection gliding under them (the stores' chip row),
* LB and RB a chip at a time, a tap or A on a far one a hop as a drop. [first] takes focus as the
* sheet opens. Too narrow for equal chips, the row scrolls.
*/
@Composable
internal fun SheetChips(labels: List<String>, selected: Int, focus: List<FocusRequester>? = null, onPick: (Int) -> Unit) {
val pal = LocalPalette.current
val bounds = remember { mutableStateMapOf<Int, Rect>() }
val tint = rememberUpdatedState(pal.signal)
// Each chip its own requester: one moved from chip to chip under a focused chip lost the ring.
val chips = focus ?: remember(labels.size) { List(labels.size) { FocusRequester() } }
var within by remember { mutableStateOf(false) }
// LB / RB with a pad on the chips: focus goes with the selection.
LaunchedEffect(selected) { if (within) runCatching { chips[selected].requestFocus() } }
BoxWithConstraints(Modifier.fillMaxWidth().onFocusChanged { within = it.hasFocus }) {
val equal = maxWidth / labels.size.coerceAtLeast(1) >= 96.dp
val scroll = rememberScrollState()
// Room inside the scroll for the focus ring, which the scroll's clip would otherwise cut.
Box(if (equal) Modifier.fillMaxWidth() else Modifier.edgeFade(scroll).horizontalScroll(scroll).padding(3.dp)) {
ChipGlide(selected, bounds[selected], tint)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = if (equal) Modifier.fillMaxWidth() else Modifier) {
labels.forEachIndexed { i, label ->
val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = 64.dp))
.onGloballyPositioned { bounds[i] = it.boundsInParent() }
.focusRequester(chips[i])
SheetChip(label, i == selected, m) { onPick(i) }
}
}
}
}
}
@Composable
private fun SheetChip(label: String, on: Boolean, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(shape)
.background(if (on) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f))
.glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/**
* A read-only line that is a link: [title], [value] under it, and "[link] ↗" at the right in the
* signal blue - not a picker, so no value box. A (or a tap) follows it. [id] is its focus key.
*/
@Composable
internal fun LinkRow(title: String, value: String, link: String, id: String, arrow: String = "↗", onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(12.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().paneItem(id).hopSource(id).heightIn(min = 56.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.glideBorder(hot, shape, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 11.dp),
) {
Column(Modifier.weight(1f).padding(end = 12.dp)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(value, fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text("$link $arrow", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
}
}
@@ -23,6 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionPrefs
@@ -152,293 +154,169 @@ class ModeSettingsActions(
val onDismiss: () -> Unit,
)
private enum class ModeSettingsTab(val label: Int) {
DISPLAY(R.string.drawer_page_display),
CONTROLS(R.string.drawer_page_controls),
GAMES(R.string.game_settings_title),
AUDIO(R.string.mode_audio),
SESSION(R.string.mode_session),
STEAM(R.string.mode_tab_steam),
/**
* The chips of a mode's lifted sheet, in order: Steam's six; the desktop's Display, Controls and
* Session (its Files chip is the Desktop page's own).
*/
internal fun modeSheetTabs(steam: Boolean): List<String> =
if (steam) listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.AUDIO, SheetTab.SESSION, SheetTab.LIBRARY, SheetTab.STEAM)
else listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.SESSION)
/** A sheet tab's chip label. */
@Composable
internal fun sheetTabLabel(tab: String): String = when (tab) {
SheetTab.DISPLAY -> stringResource(R.string.drawer_page_display)
SheetTab.CONTROLS -> stringResource(R.string.drawer_page_controls)
SheetTab.AUDIO -> stringResource(R.string.mode_audio)
SheetTab.SESSION -> stringResource(R.string.mode_session)
SheetTab.LIBRARY -> stringResource(R.string.stores_tab_library)
SheetTab.STEAM -> stringResource(R.string.mode_tab_steam)
else -> stringResource(R.string.setup_tool_files)
}
/**
* Rows the front end adds to a sheet's tabs: frame generation (Display), Performance (Session) and
* the Graphics drivers link to Components (Display, given the drivers' summary).
*/
class SheetExtras(
val frameGen: @Composable () -> Unit = {},
val performance: @Composable () -> Unit = {},
val drivers: @Composable (summary: String?) -> Unit = {},
)
/** One tab of a mode's lifted sheet: the rows the settings page had, grouped by what they are for. */
@Composable
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SheetExtras = SheetExtras()) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
val tabs = if (steam) ModeSettingsTab.entries else listOf(
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
)
val tab = tabs[tabIndex]
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
fun selectTab(index: Int) { host.open = null; tabIndex = index }
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
androidx.compose.runtime.withFrameNanos { }
runCatching {
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
else tabFocus[tabIndex].requestFocus()
if (tab == SheetTab.DISPLAY) {
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution)
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
extras.frameGen()
extras.drivers(s.gpuDrivers)
}
if (tab == SheetTab.CONTROLS) {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam && s.unfoldedControls != null) ToggleRow(
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint),
s.unfoldedControls, onChange = a.onUnfoldedControls,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
SettingsPage(
host,
title = null,
onBack = a.onDismiss,
scroll = scrolls[tabIndex],
modifier = Modifier.bumpers(
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
onNext = { selectTab((tabIndex + 1) % tabs.size) },
),
header = {
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
},
) {
if (tab == ModeSettingsTab.DISPLAY) {
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
chipModifier = Modifier.focusRequester(firstChip))
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() }
}
}
}
if (tab == ModeSettingsTab.CONTROLS) {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam && s.unfoldedControls != null) ToggleRow(
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint),
s.unfoldedControls, onChange = a.onUnfoldedControls,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
}
}
if (tab == ModeSettingsTab.SESSION) {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
}
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
s.deckyStage != null -> s.deckyStage
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
}
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
val action = when {
s.deckyStage != null -> stringResource(R.string.common_working)
s.deckyChecking -> stringResource(R.string.common_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
else -> stringResource(R.string.common_check)
}
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
action,
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
) {
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
else s.deckyLatestRelease?.let(a.onDeckyInstall)
}
if (s.deckyInstalled != null) SecondaryButton(
stringResource(R.string.common_uninstall),
enabled = s.deckyStage == null && !s.deckySessionRunning,
) { confirmDeckyRemoval = true }
}
}
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
else stringResource(R.string.mode_decky_off),
s.deckyEnabled,
enabled = !s.deckySessionRunning && s.deckyStage == null,
onChange = a.onDeckyEnabled,
)
SettingsRow(
stringResource(R.string.mode_decky_plugins),
stringResource(R.string.mode_decky_plugins_hint),
) {
SecondaryButton(
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
}
if (s.deckyStage != null && s.deckyPercent >= 0) {
LinearProgressIndicator(
progress = { s.deckyPercent / 100f },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
else -> stringResource(R.string.mode_wifi_discovery_hint)
}
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
}
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
host.open = null
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
}
}
}
}
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (s.syncBackend != null) SettingsRow(
stringResource(R.string.sync_backend_title),
stringResource(R.string.sync_backend_hint),
) {
SegmentedTabs(
listOf(
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
),
s.syncBackend,
) { id -> host.open = null; a.onSyncBackend(id) }
}
ChoiceRow(
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
GameEnvironmentRow()
}
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
if (tab == SheetTab.AUDIO && steam) {
if (s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
}
if (tab == SheetTab.SESSION) {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (steam) listOf(
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
host, "background-downloads", stringResource(R.string.mode_background_downloads),
stringResource(R.string.mode_background_downloads_hint),
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
extras.performance()
}
if (tab == SheetTab.LIBRARY && steam) {
if (s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage }
// Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with.
class StorageChoice(val path: String, val label: String, val chip: String, val saved: String)
@@ -481,6 +359,96 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (tab == SheetTab.STEAM && steam) {
SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
s.deckyStage != null -> s.deckyStage
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
}
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
val action = when {
s.deckyStage != null -> stringResource(R.string.common_working)
s.deckyChecking -> stringResource(R.string.common_checking)
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
else -> stringResource(R.string.common_check)
}
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
action,
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
) {
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
else s.deckyLatestRelease?.let(a.onDeckyInstall)
}
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
}
}
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
else stringResource(R.string.mode_decky_off),
s.deckyEnabled,
enabled = !s.deckySessionRunning && s.deckyStage == null,
onChange = a.onDeckyEnabled,
)
SettingsRow(
stringResource(R.string.mode_decky_plugins),
stringResource(R.string.mode_decky_plugins_hint),
) {
SecondaryButton(
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
onClick = a.onPickDeckyPluginZip,
)
}
if (s.deckyStage != null && s.deckyPercent >= 0) {
LinearProgressIndicator(
progress = { s.deckyPercent / 100f },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
else -> stringResource(R.string.mode_wifi_discovery_hint)
}
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
}
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
host.open = null
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
}
}
}
}
}
if (explainWifiDiscovery) AlertDialog(
onDismissRequest = { explainWifiDiscovery = false },
title = { Text(stringResource(R.string.mode_wifi_explain_title)) },
@@ -494,15 +462,66 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
TextButton(onClick = { explainWifiDiscovery = false }) { Text(stringResource(R.string.common_cancel)) }
},
)
if (confirmDeckyRemoval) AlertDialog(
onDismissRequest = { confirmDeckyRemoval = false },
title = { Text(stringResource(R.string.mode_decky_remove_title)) },
text = { Text(stringResource(R.string.mode_decky_remove_text)) },
confirmButton = {
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text(stringResource(R.string.common_uninstall)) }
},
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text(stringResource(R.string.common_cancel)) } },
}
/**
* The options that apply to every Proton - how Wine syncs, FEX's preset, Force SSBS and the games'
* environment - for the Components page's Proton layer.
*/
@Composable
internal fun ProtonOptions(
syncBackend: String, fexPreset: String, forceSsbs: Boolean,
onSyncBackend: (String) -> Unit, onFexPreset: (String) -> Unit, onForceSsbs: (Boolean) -> Unit,
) {
val host = rememberMenuHost()
SettingsRow(stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint)) {
SegmentedTabs(
listOf(
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
),
syncBackend,
) { id -> host.open = null; onSyncBackend(id) }
}
ChoiceRow(
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, fexPreset,
note = stringResource(FexPreset.byId(fexPreset).detail), onPick = onFexPreset,
)
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), forceSsbs, onChange = onForceSsbs)
GameEnvironmentRow()
}
/**
* Uninstall for Decky Loader: are you sure steps out of it in red, Cancel first, so a stray A
* costs nothing.
*/
@Composable
private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) {
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.common_uninstall)
val title = stringResource(R.string.mode_decky_remove_title)
val note = stringResource(R.string.mode_decky_remove_text)
val cancel = stringResource(R.string.common_cancel)
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
androidx.compose.foundation.layout.Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
handle = { Text(label, fontSize = 15.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(title)
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.stepItem(2)) {
StepChoice(cancel, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
}
})
}
}
}
/** Width × height for the session, with the common handheld shapes one tap away. */
@@ -44,9 +44,38 @@ import kotlin.math.sin
// Play (LaunchFlood): the control stretches out over the pane on loose springs, the far edges
// first, and the page rises into it once it is covered. Back draws it down into the control again.
/** Where a page opened from: the control's bounds and corner radius, in px. */
internal class Origin(val bounds: Rect, val corner: Float) {
fun translate(by: Offset) = Origin(bounds.translate(by), corner)
/**
* Where a page opened from: the control's bounds and corner radius, in px, and the rail item that
* was showing it ([owner]), so the page only draws back into it when that content returns. [art]
* is the control's picture for the tile (a drawable id, or an image Coil loads), if it has one.
*/
internal class Origin(val bounds: Rect, val corner: Float, val owner: String? = null, val art: Any? = null) {
fun translate(by: Offset) = Origin(bounds.translate(by), corner, owner, art)
fun ownedBy(key: String) = Origin(bounds, corner, key, art)
}
/** How a page that opened from a control leaves. */
internal enum class PageExit {
/** Back into the control it came from, which is on screen again. */
Drain,
/** Out through a link into another section, which takes the page there itself. */
Hop,
/** Away with the pane, as any page goes: the control it came from is not coming back. */
Sink,
}
/** Why a page is leaving: an ordinary change of page, or a link into another section. */
internal enum class PageLeave { Ordinary, Link }
/**
* The exit for a page opened from a control on [owner]'s content, leaving for [incoming]. It only
* draws back into the control when the content that control belongs to is what comes back; a page
* left for another rail item or another page drained into a control that was no longer there.
*/
internal fun pageExit(owner: String?, incoming: String, leave: PageLeave): PageExit = when {
owner != null && incoming == owner -> PageExit.Drain
leave == PageLeave.Link -> PageExit.Hop
else -> PageExit.Sink
}
internal object PageOrigin {
@@ -70,6 +99,7 @@ internal const val PAGE_RETURN_MS = 900
* [content], a page that opened from the control at [from] (in this element's coordinates): the
* control's tile stretching over it until covered, then the page rising in as the tile fades.
* Once [leaving], the reverse: the tile comes back over the page and draws down into the control.
* A page that [sinks] instead goes with the pane and draws no tile at all.
* The focus ring stays out of it until it is done.
*/
@Composable
@@ -78,6 +108,7 @@ internal fun PageFlood(
leaving: Boolean,
/** What the tile shows instead of the control's fill: a game card's art, cropped to the tile as it grows. */
art: Painter? = null,
sinks: Boolean = false,
content: @Composable () -> Unit,
) {
val pal = LocalPalette.current
@@ -87,7 +118,14 @@ internal fun PageFlood(
val shown = remember { Animatable(0f) }
val tile = remember { Animatable(1f) }
var size by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(size != Size.Zero, leaving) {
LaunchedEffect(size != Size.Zero, leaving, sinks) {
if (sinks) {
// Whatever the flood in was doing, the page is shown whole and leaves with the pane.
tile.snapTo(0f)
shown.snapTo(1f)
glide?.hidden = false
return@LaunchedEffect
}
if (size == Size.Zero) return@LaunchedEffect
glide?.hidden = true
val b = from.bounds
@@ -1,88 +0,0 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean)
@Composable
fun ProtonPage(
rows: List<ProtonRow>,
busyId: String?,
stage: String?,
percent: Int,
runtimeReady: Boolean,
sessionRunning: Boolean,
onInstall: (String) -> Unit,
onCancel: (String) -> Unit,
onRemove: (String) -> Unit,
onBack: () -> Unit,
) {
val host = rememberMenuHost()
val colors = MaterialTheme.colorScheme
SettingsPage(
host,
title = stringResource(R.string.setup_tool_protons),
eyebrow = stringResource(R.string.setup_title),
lede = stringResource(R.string.proton_lede),
onBack = onBack,
) {
if (!runtimeReady) Text(
stringResource(R.string.content_needs_runtime),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
)
if (sessionRunning) Text(
stringResource(R.string.proton_stop_first),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
)
val compatible = stringResource(R.string.steam_compat_label)
Text(
stringResource(R.string.proton_compatible_note, compatible),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
)
SettingsGroup(stringResource(R.string.proton_available)) {
for (row in rows) {
SettingsRow(
row.name,
when {
row.installed != null -> stringResource(R.string.proton_installed_compatible, row.installed, compatible)
row.queued -> stringResource(R.string.proton_queued)
else -> stringResource(R.string.proton_not_installed)
},
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
when {
row.installed != null -> SecondaryButton(stringResource(R.string.store_remove), enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) }
busyId != null -> SecondaryButton(if (busyId == row.id) stringResource(R.string.store_installing) else stringResource(R.string.store_install), enabled = false) {}
else -> SecondaryButton(stringResource(R.string.proton_install_now), enabled = runtimeReady && !sessionRunning) { onInstall(row.id) }
}
if (row.queued && row.installed == null && busyId == null) {
SecondaryButton(stringResource(R.string.proton_cancel_queued), enabled = !sessionRunning) { onCancel(row.id) }
}
}
}
if (busyId == row.id) Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
Text(
if (stage != null && percent >= 0) stringResource(R.string.store_busy_percent_short, stage, percent) else stage ?: stringResource(R.string.store_starting),
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
)
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
}
}
}
}
@@ -423,6 +423,8 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable ColumnScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// In a lifted sheet each group fades and rises in on its own beat.
Column(Modifier.fillMaxWidth().sheetPiece()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 10.dp),
@@ -432,6 +434,7 @@ fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable
Box(modifier = Modifier.weight(1f).height(1.dp).background(pal.line))
}
Column(modifier = Modifier.fillMaxWidth().clip(GroupShape).background(colors.surface).border(1.dp, pal.line, GroupShape)) { content() }
}
}
/**
@@ -0,0 +1,237 @@
package com.droiddeck.launcher.ui
import android.os.SystemClock
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.sin
// The moves the stores made first, for any section: one selection shape that glides along a row
// (or down a column) of choices, and a pressed control that squashes into a dot and flies home.
/** Which way a [ChipGlide]'s choices run. */
internal enum class GlideAxis { Horizontal, Vertical }
/**
* The selection under a row of chips: [at] (in the row) of chip [index], in [tint]. A move to the
* next chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a
* beat later on its trail spring - and a hop past a chip pinches it to a drop, carries it and
* opens it. [axis] runs it down a column instead (the rail, a stack of layers). [corner] is the
* chips' own corner; null draws a capsule. [fill] and [outline] are the resting alphas.
*/
@Composable
internal fun BoxScope.ChipGlide(
index: Int,
at: Rect?,
tint: State<Color>,
axis: GlideAxis = GlideAxis.Horizontal,
corner: Dp? = null,
fill: Float = 0.16f,
outline: Float = 0.7f,
) {
val density = androidx.compose.ui.platform.LocalDensity.current
// The two edges along the axis (left and right, or top and bottom), and the two across it.
val start = remember { Animatable(Float.NaN) }
val end = remember { Animatable(0f) }
val pinch = remember { Animatable(0f) }
val crossStart = remember { mutableStateOf(0f) }
val crossEnd = remember { mutableStateOf(0f) }
val was = remember { intArrayOf(-1) }
val vertical = axis == GlideAxis.Vertical
LaunchedEffect(index, at) {
val b = at ?: return@LaunchedEffect
val s = if (vertical) b.top else b.left
val e = if (vertical) b.bottom else b.right
crossStart.value = if (vertical) b.left else b.top
crossEnd.value = if (vertical) b.right else b.bottom
val from = was[0]
was[0] = index
if (start.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) {
start.snapTo(s); end.snapTo(e); pinch.snapTo(0f)
return@LaunchedEffect
}
coroutineScope {
if (abs(index - from) > 1) {
// Too far to stretch: pinch into a drop where it is, carry it over, open it there.
pinch.animateTo(1f, Motion.tw(90))
val l = launch { start.animateTo(s, Motion.sp(0.7f, 380f)) }
val r = launch { end.animateTo(e, Motion.sp(0.7f, 380f)) }
l.join(); r.join()
pinch.animateTo(0f, Motion.sp(0.6f, 500f))
} else {
val forward = index > from
val (lead, trail) = if (forward) end to start else start to end
val leadTo = if (forward) e else s
val trailTo = if (forward) s else e
launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) }
launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) }
}
}
}
Canvas(Modifier.matchParentSize()) {
if (start.value.isNaN() || crossEnd.value <= crossStart.value) return@Canvas
val p = pinch.value
val shape = glideShape(start.value, end.value, crossStart.value, crossEnd.value, p, with(density) { 6.dp.toPx() }, vertical)
val at0 = shape.topLeft
val size = shape.size
val round = minOf(size.width, size.height) / 2f
val r = CornerRadius(corner?.let { lerp(minOf(with(density) { it.toPx() }, round), round, p) } ?: round)
// A drop in flight is solid; on a chip it is the chip's tinted fill and outline.
drawRoundRect(tint.value.copy(alpha = lerp(fill, 0.9f, p)), at0, size, r)
if (outline > 0f) {
// Inside the chip's edge: a stroke on it would lose its outer half to a scrolling row's clip.
val w = with(density) { 1.dp.toPx() }
val inset = w / 2f
drawRoundRect(
tint.value.copy(alpha = outline * (1f - p)), at0 + Offset(inset, inset), Size(size.width - w, size.height - w),
CornerRadius((r.x - inset).coerceAtLeast(0f)), style = Stroke(w),
)
}
}
}
/**
* Where a glide's shape is: its edges along the axis at [start] and [end] (either order), across
* it from [crossStart] to [crossEnd], pinched [pinch] of the way (0 to 1) into a drop [drop] each
* side of its centre. [vertical] runs the axis down instead of across.
*/
internal fun glideShape(start: Float, end: Float, crossStart: Float, crossEnd: Float, pinch: Float, drop: Float, vertical: Boolean): Rect {
val a0 = minOf(start, end)
val a1 = maxOf(start, end)
val ca = (a0 + a1) / 2f
val cc = (crossStart + crossEnd) / 2f
val s0 = lerp(a0, ca - drop, pinch)
val s1 = lerp(a1, ca + drop, pinch)
val c0 = lerp(crossStart, cc - drop, pinch)
val c1 = lerp(crossEnd, cc + drop, pinch)
return if (vertical) Rect(c0, s0, c1, s1) else Rect(s0, c0, s1, c1)
}
/**
* A dot on its way somewhere: from the control at [from] (root px) to the point [to] gives (root
* px, asked each frame, so a target that moves is followed). [color] null is the palette's signal
* blue. [onHalf] runs as it passes the top of its arc, [onLand] once it lands.
*/
internal class Flight(
val from: Rect,
val to: Density.() -> Offset?,
val color: Color? = null,
val onHalf: () -> Unit = {},
val onLand: () -> Unit = {},
) {
val id = SystemClock.uptimeMillis() * 1000 + (next++ % 1000)
private companion object { var next = 0L }
}
/** Dots flying anywhere outside the Stores page (which keeps its own): a hop, a swap, a toggle. */
internal object Flights {
val all = mutableStateListOf<Flight>()
/** Sends [f], or with animations off just runs its ends. */
fun fly(f: Flight) {
if (Motion.scale == 0f) { f.onHalf(); f.onLand(); return }
all += f
}
}
/** [Flights] drawn over everything, in the space of an element at [origin] (root px). */
@Composable
internal fun FlightsLayer(origin: Offset) {
for (f in Flights.all.toList()) key(f.id) { FlightDot(f, origin) { Flights.all.remove(f) } }
}
/**
* One dot: the control squashes into a 14dp dot (width first, a little bloop), which then flies
* on an arc to its target, stretched along its path like the focus ring's drop.
*/
@Composable
internal fun FlightDot(f: Flight, origin: Offset, onDone: () -> Unit) {
val pal = LocalPalette.current
val color = f.color ?: pal.signal
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) {
t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing))
coroutineScope {
launch { delay(Motion.ms(150).toLong()); f.onHalf() }
t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing))
}
f.onLand()
onDone()
}
Canvas(Modifier.fillMaxSize()) {
val r = 7.dp.toPx()
val from = f.from.translate(-origin)
val a = from.center
val b = (f.to(this) ?: return@Canvas) - origin
val v = t.value
if (v < 1f) {
// Squash: the width goes first, then the height, into the dot; a bloop at the end.
val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f))
val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f))
val k = 1f - 0.15f * sin(PI * v).toFloat()
drawRoundRect(color, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2))
} else {
val p = v - 1f
val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx())
val q = 1f - p
val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y)
val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y))
val stretch = sin(PI * p).toFloat()
val angle = atan2(d.y, d.x) * 180f / PI.toFloat()
rotate(angle, pos) {
val w = 2 * r * (1f + 0.5f * stretch)
val h = 2 * r * (1f - 0.25f * stretch)
drawOval(color, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h))
}
}
}
}
/**
* A row that scrolls sideways fades out at an edge it has more past, instead of slicing a pill or
* a card through: the content's own alpha, so whatever is behind the row shows through.
*/
internal fun Modifier.edgeFade(scroll: androidx.compose.foundation.ScrollState, width: Dp = 28.dp): Modifier =
this.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
val w = width.toPx().coerceAtMost(size.width / 3f)
if (scroll.value > 0) drawRect(
androidx.compose.ui.graphics.Brush.horizontalGradient(0f to Color.Transparent, 1f to Color.Black, startX = 0f, endX = w),
size = Size(w, size.height), blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
)
if (scroll.value < scroll.maxValue) drawRect(
androidx.compose.ui.graphics.Brush.horizontalGradient(0f to Color.Black, 1f to Color.Transparent, startX = size.width - w, endX = size.width),
topLeft = Offset(size.width - w, 0f), size = Size(w, size.height), blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
)
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
// The rows the lifted sheets add to what the session settings already had: frame generation,
// Performance and the link to the GPU drivers on Components.
/** Frame generation, moved here from Setup: it is how Play's games are drawn. */
@Composable
internal fun FrameGenGroup(s: FrontEndState, a: FrontEndActions) {
val host = rememberMenuHost()
SettingsGroup(stringResource(R.string.frame_gen_title)) {
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint), highlighted = host.open == "fg") {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless)
}
}
}
}
/**
* Performance (CPU core assignment), opened from the sheet's Session chip: its page floods out of
* this row, and Back draws it into the row again with the sheet lifted where it was.
*/
@Composable
internal fun PerformanceLink(a: FrontEndActions, rail: String, tab: String) {
val id = "sheet:performance"
val title = stringResource(R.string.setup_tool_performance)
SettingsGroup(title) {
LinkRow(title, stringResource(R.string.setup_tool_performance_hint), stringResource(R.string.store_open), id, arrow = "›") {
Hops.sources[id]?.let { at ->
PageOrigin.mark(Origin(at, 12f * Hops.density))
Hops.restore = HopOrigin(rail, HOP_SURFACE_SHEET, tab, id, at, "", dest = rail, page = "performance")
}
a.onPerformance()
}
}
}
/**
* The GPU drivers in use, read only, and a link to where they are set: A hops to Components with
* the GPU drivers layer chosen, and B there comes back to this row.
*/
@Composable
internal fun DriversLink(a: FrontEndActions, summary: String?, rail: String, back: String) {
val id = "sheet:gpu-drivers"
SettingsGroup(stringResource(R.string.mode_drivers)) {
LinkRow(
stringResource(R.string.sheet_graphics_drivers), summary ?: stringResource(R.string.common_auto),
stringResource(R.string.rail_components), id,
) {
val at = Hops.sources[id]
if (at == null) a.onComponentsTab(GPU_TAB)
else Hops.go(HopOrigin(rail, HOP_SURFACE_SHEET, SheetTab.DISPLAY, id, at, back, dest = "components", page = "components")) {
a.onComponentsTab(GPU_TAB)
}
}
}
}
@@ -58,6 +58,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.inset
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import coil.compose.rememberAsyncImagePainter
@@ -82,41 +83,52 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
// came out differently from one read of the library to the next moved every capsule.
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
val narrow = LocalNarrowPane.current
val colors = MaterialTheme.colorScheme
// Where the words and buttons sit, for the empty library's wandering mark to keep off.
var words by remember { mutableStateOf<Rect?>(null) }
Box(modifier = modifier.fillMaxSize().clipToBounds()) {
when {
games.isEmpty() -> EmptyLibrary(avoid = words) { WallFade() }
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
else -> {
CapsuleWall(games)
WallFade()
val mode = com.droiddeck.launcher.session.SessionService.MODE_STEAM
val tabs = modeSheetTabs(steam = true).map { it to sheetTabLabel(it) }
val back = stringResource(R.string.hop_from_play)
val extras = SheetExtras(
frameGen = { FrameGenGroup(s, a) },
performance = { PerformanceLink(a, "steam", SheetTab.SESSION) },
drivers = { summary -> DriversLink(a, summary, "steam", back) },
)
LiftHero(
key = "steam", tabs = tabs,
onOpen = a.onSteamSettings, onPrimary = a.onPlay,
modifier = modifier.clipToBounds(),
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
background = {
when {
// The mark wanders over this fade, so it sinks with the wall: drawn a little oversized,
// it still reaches the pane's edges at the sink's 0.95.
games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
else -> CapsuleWall(games)
}
}
Column(
modifier = Modifier.align(Alignment.BottomStart)
.padding(start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, end = 20.dp)
.onPlaced { words = it.boundsInParent() },
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
},
scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() },
words = {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.onPlaced { words = it.boundsInParent() },
) {
Wordmark(if (narrow) 30.dp else 40.dp)
// Said only when something stands between Play and Steam; a ready runtime needs no words.
if (s.busy || !s.ready || (s.available != null && s.available != s.installed)) RuntimeChip(s)
}
Spacer(Modifier.height(18.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
// Enabled without a runtime: the session's loading screen installs it first.
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = a.onPlay)
Cog(size = 54.dp, onClick = a.onSteamSettings)
}
}
}
},
button = { click ->
// Enabled without a runtime: the session's loading screen installs it first.
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = click)
},
rows = { tab -> a.modeSheet(mode, tab, extras) },
)
}
/** The wall fades out behind the words, and toward the bottom where the buttons sit. */
@Composable
private fun WallFade() {
internal fun WallFade() {
val colors = MaterialTheme.colorScheme
Box(
Modifier.fillMaxSize().background(
@@ -147,8 +159,12 @@ internal fun CapsuleWall(
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
}
/** One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. */
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null)
/**
* One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. A
* tile that is an app rather than a cover carries an [icon] instead (a drawable id or an image),
* drawn whole at the tile's centre, and a window's [bar] along its foot.
*/
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null)
/**
* [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] describes number i
@@ -161,25 +177,28 @@ internal fun TiltedWall(
count: Int,
driftMs: Int,
modifier: Modifier = Modifier,
/** One tile's size, the gap between tiles and their corner: portrait capsules unless a wall says otherwise. */
capW: androidx.compose.ui.unit.Dp = 112.dp,
capH: androidx.compose.ui.unit.Dp = 168.dp,
gap: androidx.compose.ui.unit.Dp = 14.dp,
corner: androidx.compose.ui.unit.Dp = 10.dp,
capsule: (index: Int) -> WallCapsule,
) {
val capW = 112.dp
val capH = 168.dp
val gap = 14.dp
// Animations off in the system settings: a still wall.
val still = Motion.scale == 0f
// The wall moves a few pixels a step. Stepped on frames at most every 15 ms it looks the same as
// stepped on every frame, which on a 120 Hz screen drew it twice as often.
// stepped on every frame, which on a 120 Hz screen drew it twice as often. The phase advances by
// the time since the last step over the period now, so a period that changes (Flathub's wall
// slowing while it sets up) changes the speed without the wall jumping.
val drift = remember { mutableFloatStateOf(0f) }
if (!still) LaunchedEffect(driftMs) {
val period = driftMs * 1_000_000.0 * Motion.scale
var start = 0L
val period = androidx.compose.runtime.rememberUpdatedState(driftMs)
if (!still) LaunchedEffect(Unit) {
var shown = 0L
while (true) withFrameNanos { now ->
if (start == 0L) start = now
if (shown == 0L || now - shown >= DRIFT_STEP_NS) {
if (shown == 0L) shown = now
else if (now - shown >= DRIFT_STEP_NS) {
drift.floatValue = ((drift.floatValue + (now - shown) / (period.value * 1_000_000.0 * Motion.scale)) % 1.0).toFloat()
shown = now
drift.floatValue = ((now - start) % period / period).toFloat()
}
}
}
@@ -196,14 +215,15 @@ internal fun TiltedWall(
val run = (capH + gap) * perRun
val cap = with(density) { Size(capW.toPx(), capH.toPx()) }
val step = with(density) { (capH + gap).toPx() }
val radius = with(density) { 10.dp.toPx() }
val radius = with(density) { corner.toPx() }
val line = with(density) { 1.dp.toPx() }
val barH = with(density) { 12.dp.toPx() }
val indexOf = { c: Int, i: Int -> if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count }
val shown = remember(count, columns, perRun) { (0 until columns).flatMap { c -> (0 until perRun).map { indexOf(c, it) } }.distinct() }
val looks = HashMap<Int, Pair<WallCapsule, Painter?>>(shown.size * 2)
for (index in shown) key(index) {
val look = capsule(index)
looks[index] = look to look.art?.let { rememberCapsuleArt(it, cap, radius) }
looks[index] = look to (look.art?.let { rememberCapsuleArt(it, cap, radius) } ?: look.icon?.let { rememberIcon(it) })
}
Row(
horizontalArrangement = Arrangement.spacedBy(gap),
@@ -224,7 +244,7 @@ internal fun TiltedWall(
for (i in 0 until perRun * 2) {
val (look, art) = looks[indexOf(c, i)] ?: continue
val top = i * step
inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line) }
inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line, barH) }
}
},
)
@@ -247,9 +267,20 @@ private fun rememberCapsuleArt(model: Any, size: Size, radius: Float): Painter {
)
}
private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float) {
/** An app's icon for a wall tile: a bundled drawable, or an image Coil loads. */
@Composable
private fun rememberIcon(model: Any): Painter =
if (model is Int) androidx.compose.ui.res.painterResource(model) else rememberAsyncImagePainter(model)
private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float, barH: Float = 0f) {
drawRoundRect(look.ground, size = size, cornerRadius = CornerRadius(radius))
if (art != null) with(art) { draw(size) }
look.bar?.let { drawRect(it, Offset(0f, size.height - barH), Size(size.width, barH)) }
if (art != null && look.icon != null) {
// An app's icon whole at the tile's centre (above a window's bar), not cropped over it.
val side = minOf(size.width, size.height - (if (look.bar != null) barH else 0f)) * 0.5f
val cy = (size.height - (if (look.bar != null) barH else 0f)) / 2f
translate((size.width - side) / 2f, cy - side / 2f) { with(art) { draw(Size(side, side)) } }
} else if (art != null) with(art) { draw(size) }
look.edge?.let {
drawRoundRect(it, Offset(line / 2, line / 2), Size(size.width - line, size.height - line), CornerRadius(radius - line / 2), style = Stroke(line))
}
@@ -184,6 +184,10 @@ internal fun StepOut(
BoxWithConstraints(Modifier.fillMaxSize()) {
val hostWidth = constraints.maxWidth.toFloat()
val hostHeight = constraints.maxHeight.toFloat()
// No room under the control (a row near the foot of the screen): it steps out upward
// instead, the same outline mirrored about the control.
val up = measured?.let { pill.bottom + pull + it.height > hostHeight && pill.top - pull - it.height >= 0f } ?: false
val room = if (side == StepSide.Left) pill.right else hostWidth - pill.left
val maxW = with(density) { maxWidth.toPx() }.coerceAtMost(room).coerceAtLeast(pill.width)
LaunchedEffect(open, measured) {
@@ -246,6 +250,7 @@ internal fun StepOut(
if (!grown) return@Canvas
if (side == StepSide.Left) stepPath(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner)
else stepPathRight(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner)
if (up) path.transform(Matrix().apply { this[1, 1] = -1f; this[3, 1] = pill.top + pill.bottom })
drawPath(path, lerp(hotFill, restFill, tint.value))
drawPath(path, accent.copy(alpha = lerpF(0.9f, 0.55f, tint.value)), style = Stroke(rim))
}
@@ -258,7 +263,7 @@ internal fun StepOut(
val x = if (side == StepSide.Left) pill.right - (measured?.width?.toFloat() ?: maxW) else pill.left
Column(
modifier = Modifier
.offset { IntOffset(x.roundToInt(), (pill.bottom + pull).roundToInt()) }
.offset { IntOffset(x.roundToInt(), (if (up) pill.top - pull - (measured?.height ?: 0) else pill.bottom + pull).roundToInt()) }
.widthIn(min = with(density) { pill.width.toDp() }, max = with(density) { maxW.toDp() })
.onSizeChanged { if (measured != it) measured = it }
.controllerBack(onDismiss)
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
// The front end's confirms and short lists, each stepping out of the control that asked for it
// (StepOut): a sheet's Uninstall, a Components swap, a remove. The Stores page keeps its own, in
// StoresMotion; everything else asks here and is drawn over the whole screen.
internal object Steps {
/** The confirm or list stepping out of a control now, and whether it is open. */
var step by mutableStateOf<StepAsk?>(null)
private set
var open by mutableStateOf(false)
private set
fun ask(a: StepAsk) {
step?.onDismiss()
step = a
open = true
}
/** Folds the current one back into its control. */
fun fold() { open = false }
/** Drops it at once: the control it came from has gone. */
fun drop() {
step?.let { step = null; it.onDismiss() }
open = false
}
internal fun closed(a: StepAsk) {
if (step === a) step = null
a.onDismiss()
}
}
/** [Steps] drawn over an element at [origin] (root px) that fills the screen. */
@Composable
internal fun StepsLayer(origin: Offset) {
val ask = Steps.step ?: return
// Back (B, or the system's) folds what is stepped out, before it reaches the page under it.
androidx.activity.compose.BackHandler(enabled = Steps.open) { Steps.fold() }
key(ask) {
StepOut(
open = Steps.open, pill = ask.anchor.translate(-origin), side = ask.side, accent = ask.accent,
onDismiss = { Steps.fold() }, onClosed = { Steps.closed(ask) },
pillCorner = ask.pillCorner, maxWidth = ask.maxWidth, items = ask.items, handle = ask.handle, content = ask.content,
)
}
}
@@ -19,6 +19,13 @@ import com.droiddeck.launcher.stores.Store
// Where a game came from, as a small coloured chip: Steam, one of the stores, or an added folder.
/** Flathub's own accent, as each store has one, and the ink that reads on it. */
internal val FlathubTeal = Color(0xFF3FB8AF)
internal val FlathubInk = Color(0xFF04211F)
/** The Store rail section's source id, for its colours. */
internal const val FLATHUB = "flathub"
/** The chip's colours per source: a tinted ground and a readable ink on it, one pair per store. */
internal class SourceColours(val fill: Color, val ink: Color, val dot: Color)
@@ -27,6 +34,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
}
@@ -1,283 +1,429 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Verified
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.store.FlathubApi
import com.droiddeck.launcher.store.StoreState
import kotlinx.coroutines.delay
// The Store rail section: Flathub's apps for this device's architecture, installed into the
// Linux runtime with Flatpak, and the ones already installed.
// Linux runtime with Flatpak, and the ones already installed - in the stores' style, in Flathub's
// teal. Until Flatpak is set up it is the stores' sign-in hero: a wall of real app icons, one word,
// one button that becomes its own progress. Then a chip row, shelves of square cards that flood
// open into an app's page, and installs that fly home to the Installed chip.
private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed)
private const val MODE_DISCOVER = "discover"
private const val MODE_INSTALLED = "installed"
private const val MODE_UPDATES = "updates"
/** One app as a card shows it: Flathub's summary or what Flatpak says is installed. */
private class CardApp(val id: String, val name: String, val icon: Any?, val summary: String? = null, val verified: Boolean = false)
/** Where the Installed chip is, for an install's dot to land in, and how many have landed. */
private object FlathubMotion {
var installedChip: Rect? = null
var landed by mutableIntStateOf(0)
}
@Composable
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
var tab by rememberSaveable { mutableStateOf(0) }
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(s.ready) { StoreState.refresh(ctx) }
LaunchedEffect(Unit) { StoreState.loadSections() }
// A pad's focus sits on something the page is about to replace - the tile that opens an app,
// a tab's contents - and would be lost with it. Each move says where focus goes next.
val ff = LocalFrontFocus.current
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
val tabFocus = remember { List(TABS.size) { androidx.compose.ui.focus.FocusRequester() } }
var focusMove by remember { mutableStateOf<Pair<String, Int>?>(null) }
var lastApp by remember { mutableStateOf<String?>(null) }
fun closeApp() { lastApp = openApp; openApp = null; focusMove = "tile" to (focusMove?.second ?: 0) + 1 }
fun switchTab(to: Int) { tab = to; focusMove = "tab" to (focusMove?.second ?: 0) + 1 }
BackHandler(enabled = openApp != null) { closeApp() }
val scroll = rememberScrollState()
LaunchedEffect(openApp, tab) { scroll.scrollTo(0) }
LaunchedEffect(focusMove, openApp) {
if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
val target = when {
openApp != null -> ff?.primary
focusMove?.first == "tile" -> lastApp?.let { ff?.items?.get("tile:store:$it") } ?: tabFocus[tab]
focusMove?.first == "tab" -> tabFocus[tab]
else -> null
} ?: return@LaunchedEffect
var landed = false
focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } }
}
Column(
modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV)
.bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) },
onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }),
) {
val id = openApp
if (id != null) {
AppDetail(s, a, id) { closeApp() }
return@Column
FlathubTheme {
// The storefront takes over from the hero once Flatpak is ready: the setup rail rings
// once, then the hero sinks away and the storefront rises.
var front by remember { mutableStateOf(StoreState.ready && s.ready) }
var ring by remember { mutableIntStateOf(0) }
LaunchedEffect(StoreState.ready, s.ready) {
val ready = StoreState.ready && s.ready
if (ready && !front) {
ring++
delay(Motion.ms(450).toLong())
front = true
} else if (!ready) front = false
}
Rise(0) {
PageHeader(stringResource(R.string.store_title)) {
Chip(stringResource(R.string.store_chip_flathub), ok = false)
if (StoreState.ready) Chip(stringResource(R.string.store_chip_ready), ok = true)
}
}
if (!s.ready) {
Rise(1) { Note(stringResource(R.string.store_needs_runtime)) }
return@Column
}
if (!StoreState.ready) Rise(1) { SetupCard() }
Rise(2) {
TabStrip(
TABS.mapIndexed { i, id -> stringResource(id).let { t -> if (i == 2 && StoreState.installed.isNotEmpty()) stringResource(R.string.store_tab_count, t, StoreState.installed.size) else t } },
tab, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus,
)
}
BusyBar()
when (tab) {
0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) })
1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it })
else -> Installed(a, onOpen = { openApp = it })
AnimatedContent(
front,
modifier = modifier,
transitionSpec = {
EnterTransition.None.togetherWith(fadeOut(Motion.tw(320)) + scaleOut(Motion.tw(320), targetScale = 0.95f))
},
label = "flathub",
) { ready ->
if (ready) Storefront(s, a) else FlathubHero(s, ring)
}
}
}
/** Flatpak is not in the runtime yet: one button puts it there. */
/**
* Flatpak is not set up yet: the stores' sign-in shape in teal. A tilted wall of the popular apps'
* icons (Flathub's public pages need no Flatpak) drifts behind, fading toward the words; one big
* button sets it up, and stretches into its own progress while it does.
*/
@Composable
private fun SetupCard() {
private fun FlathubHero(s: FrontEndState, ring: Int) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val icons = remember(StoreState.sections.values.toList()) {
StoreState.sections.values.flatten().mapNotNull { it.icon }.distinct().take(48).takeIf { it.size >= 8 }.orEmpty()
}
val busy = StoreState.busy == "setup"
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Text(stringResource(R.string.store_setup_title), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(
stringResource(R.string.store_setup_text),
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
Actions {
PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) }
BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp)) {
val shape = RoundedCornerShape(20.dp)
Box(Modifier.fillMaxWidth().height(maxHeight.coerceAtLeast(320.dp)).clip(shape).background(colors.background).border(1.dp, pal.line, shape)) {
// Slower while it sets up, as the empty wall drifts.
androidx.compose.animation.Crossfade(icons, animationSpec = Motion.tw(600), label = "flathubWall") { tiles ->
val ground = Brush.linearGradient(listOf(Color(0xFF0B2E2B), Color(0xFF0B2E2B)))
val edge = FlathubTeal.copy(alpha = 0.18f)
TiltedWall(tiles.size, if (tiles.isEmpty() || busy) 75_000 else 50_000, capW = 68.dp, capH = 68.dp, gap = 14.dp, corner = 17.dp) { i ->
WallCapsule(ground, edge = edge, icon = tiles.getOrNull(i))
}
}
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to colors.background.copy(alpha = 0.97f), 0.45f to colors.background.copy(alpha = 0.9f),
0.78f to colors.background.copy(alpha = 0.35f), 1f to colors.background.copy(alpha = 0.15f),
),
),
)
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.5f to Color.Transparent, 1f to colors.background.copy(alpha = 0.92f))))
Box(
Modifier.fillMaxSize().drawBehind {
drawRect(Brush.radialGradient(listOf(FlathubTeal.copy(alpha = 0.22f), Color.Transparent), center = Offset(size.width * 0.16f, size.height * 0.9f), radius = size.height * 0.75f))
},
)
BoxWithConstraints(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(start = if (narrow) 20.dp else 40.dp, bottom = if (narrow) 20.dp else 36.dp, end = 20.dp)) {
val full = maxWidth
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Rise(1) {
Text(stringResource(R.string.flathub_eyebrow), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp, color = FlathubTeal)
}
Rise(2) {
Text(
stringResource(R.string.flathub_name), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp,
fontWeight = FontWeight.Black, color = colors.onBackground,
)
}
Rise(3) { Text(stringResource(R.string.flathub_line), fontSize = 15.sp, color = colors.onSurfaceVariant) }
Spacer(Modifier.height(8.dp))
Rise(4) { SetupButton(s, busy, full, ring) { StoreState.setup(ctx) } }
}
}
}
}
}
/** The running install, update or setup, whichever page it was started from. */
/**
* Set up Flathub, and its own progress: while it sets up the button springs out to the full width
* and fills in teal from the left with how far it is, the stage and the figure inside it. Without
* a runtime it says what is missing instead.
*/
@Composable
private fun BusyBar() {
private fun SetupButton(s: FrontEndState, busy: Boolean, full: Dp, ring: Int, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val busy = StoreState.busy ?: return
val name = StoreState.installed.firstOrNull { it.id == busy }?.name ?: StoreState.details[busy]?.name
?: when (busy) { "setup" -> stringResource(R.string.store_flatpak); "update-all" -> stringResource(R.string.store_updates); else -> busy.substringAfterLast('.') }
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) {
val stage = StoreState.stage
Text(
if (stage != null && StoreState.percent >= 0) stringResource(R.string.store_busy_percent, name, stage, StoreState.percent) else stringResource(R.string.store_busy, name, stage ?: stringResource(R.string.store_starting)),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 6.dp),
)
if (StoreState.percent >= 0) LinearProgressIndicator(progress = { StoreState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
}
@Composable
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) }
Rise(3) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } }
val density = LocalDensity.current
val ff = LocalFrontFocus.current
val inputMode = LocalInputModeManager.current
var restW by remember { mutableStateOf(0.dp) }
val grow by animateFloatAsState(if (busy) 1f else 0f, Motion.sp(0.7f, 380f), label = "setupGrow")
val fill by animateFloatAsState((StoreState.percent.coerceAtLeast(0) / 100f), Motion.tw(300), label = "setupFill")
if (!busy && grow < 0.01f) {
Box(Modifier.onSizeChanged { restW = with(density) { it.width.toDp() } }) {
PrimaryButton(
if (s.ready) stringResource(R.string.flathub_setup) else stringResource(R.string.store_needs_runtime),
enabled = s.ready && StoreState.busy == null, main = true, large = true, icon = Icons.Filled.Download, onClick = onClick,
)
}
}
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
Rise(4) { Note(stringResource(R.string.store_unreachable)) }
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } }
return
}
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
val apps = StoreState.sections[key]
Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) }
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
// A pad keeps its place on the rail that took the button's.
if (ff != null) LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { ff.primary } }
val width = androidx.compose.ui.unit.lerp(restW.coerceAtLeast(120.dp), full, grow)
val shape = RoundedCornerShape(12.dp)
val shimmer = rememberInfiniteTransition(label = "setupShimmer")
val sweep by shimmer.animateFloat(-0.3f, 1.3f, infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart), label = "sweep")
val stage = StoreState.stage ?: stringResource(R.string.store_starting)
val label = if (StoreState.percent >= 0) "$stage · ${StoreState.percent}%" else stage
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.width(width).heightIn(min = 56.dp)
.then(if (ff != null) Modifier.focusRequester(ff.primary) else Modifier)
.pulseRing(ring, FlathubTeal)
.clip(shape).background(colors.surfaceVariant).border(1.dp, FlathubTeal.copy(alpha = 0.5f), shape)
.drawBehind {
if (StoreState.percent >= 0) drawRect(FlathubTeal, size = size.copy(width = size.width * fill))
else if (Motion.scale != 0f) {
val w = size.width * 0.3f
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, FlathubTeal.copy(alpha = 0.55f), Color.Transparent), startX = size.width * sweep - w, endX = size.width * sweep + w))
}
}
.focusable()
.semantics { contentDescription = label }
.padding(horizontal = 20.dp, vertical = 16.dp),
) {
Text(label, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = if (StoreState.percent >= 50) FlathubInk else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
// ---- storefront ------------------------------------------------------------------------------------
@Composable
private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) {
val view = androidx.compose.ui.platform.LocalView.current
val run = {
// The field is an Android EditText; Done leaves its keyboard over the results otherwise.
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
?.hideSoftInputFromWindow(view.windowToken, 0)
private fun Storefront(s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
val narrow = LocalNarrowPane.current
val colors = MaterialTheme.colorScheme
val density = LocalDensity.current
val padH = if (narrow) 14.dp else 22.dp
var mode by rememberSaveable { mutableStateOf(MODE_DISCOVER) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf<String?>(null) }
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
val updates = StoreState.updates
if (mode == MODE_UPDATES && updates.isEmpty()) mode = MODE_INSTALLED
// Once a session is plenty: the Updates chip shows itself when there is something to update.
LaunchedEffect(StoreState.ready) { if (StoreState.ready && !StoreState.updatesChecked && !StoreState.checkingUpdates) StoreState.checkUpdates(ctx) }
// A search runs a beat after typing stops, or as a category is picked.
LaunchedEffect(query, category) {
if (query.isBlank() && category == null) { StoreState.clearSearch(); return@LaunchedEffect }
delay(350)
StoreState.search(query.trim(), category)
}
Rise(3) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
AdbTextField(
value = query, onValueChange = onQuery, label = stringResource(R.string.store_search_label), keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done, placeholder = stringResource(R.string.store_search_placeholder), modifier = Modifier.weight(1f), onDone = run,
)
PrimaryButton(stringResource(R.string.store_search), main = true, onClick = run)
val ff = LocalFrontFocus.current
val inputMode = LocalInputModeManager.current
// Cards, where they are, for the page that opens from one.
val cards = remember { HashMap<String, Rect>() }
var viewportAt by remember { mutableStateOf(Offset.Zero) }
var detail by remember { mutableStateOf<String?>(null) }
var leaving by remember { mutableStateOf(false) }
var detailFrom by remember { mutableStateOf<Origin?>(null) }
var lastApp by remember { mutableStateOf<String?>(null) }
LaunchedEffect(openApp) {
val id = openApp
if (id != null) {
detailFrom = cards[id]?.let { Origin(it.translate(-viewportAt), 16f * density.density) }
leaving = false
detail = id
if (inputMode.inputMode == InputMode.Keyboard && ff != null) { delay(Motion.ms(300).toLong()); focusSoon { ff.primary } }
} else if (detail != null) {
lastApp = detail
if (detailFrom != null) { leaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) }
detail = null; leaving = false; detailFrom = null
}
}
Rise(4) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
FlathubApi.categories.forEach { c ->
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
// Back on the grid: focus goes to the card the page came from.
LaunchedEffect(detail) {
if (detail != null || inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect
val id = lastApp ?: return@LaunchedEffect
ff.items["tile:store:$id"]?.let { req -> focusSoon { req } }
}
BackHandler(enabled = openApp != null) { openApp = null }
// An app's card on Desktop asked for its page.
LaunchedEffect(StoreState.openRequest) { StoreState.openRequest?.let { openApp = it; StoreState.openRequest = null } }
val covering = detail != null && !leaving
val sink by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "flathubSink")
val fade by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "flathubFade")
val modes = buildList { add(MODE_DISCOVER); add(MODE_INSTALLED); if (updates.isNotEmpty()) add(MODE_UPDATES) }
// LB / RB with a pad: focus goes to the new mode's chip; the cards it was on leave with the old mode.
val seenMode = remember { arrayOf(mode) }
LaunchedEffect(mode) {
if (seenMode[0] == mode) return@LaunchedEffect
seenMode[0] = mode
if (inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect
ff.items["flathub:$mode"]?.let { req -> focusSoon { req } }
}
Column(
Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
.bumpers(
onPrevious = { if (openApp == null) mode = modes[(modes.indexOf(mode) + modes.size - 1) % modes.size] },
onNext = { if (openApp == null) mode = modes[(modes.indexOf(mode) + 1) % modes.size] },
),
) {
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
// A page of Desktop's: the way back to it, for a finger (B does the same).
val show = LocalShowSection.current
BackLink(stringResource(R.string.rail_desktop), compact = true) { show("desktop") }
Box(Modifier.weight(1f)) { FlathubChips(mode, modes, StoreState.installed.size, updates.size) { mode = it } }
}
}
}
val results = StoreState.searchResults
when {
StoreState.searching -> Rise(5) { SectionTitle(stringResource(R.string.store_searching), null) }
results == null -> Rise(5) { Note(stringResource(R.string.store_search_hint)) }
results.isEmpty() -> Rise(5) { Note(stringResource(R.string.store_no_results)) }
else -> {
Rise(5) { SectionTitle(stringResource(R.string.store_results), results.size.toString()) }
Rise(6) { AppGrid(results, first = true, onOpen = onOpen) }
Spacer(Modifier.height(8.dp))
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().onGloballyPositioned { viewportAt = it.positionInRoot() }) {
Column(
Modifier.fillMaxSize().graphicsLayer { scaleX = sink; scaleY = sink; alpha = fade }.verticalScroll(rememberScrollState()).padding(bottom = 16.dp),
) {
val open: (String) -> Unit = { openApp = it }
when (mode) {
MODE_DISCOVER -> Discover(query, category, { query = it }, { category = if (category == it) null else it }, cards, open)
MODE_INSTALLED -> InstalledMode(cards, open)
else -> UpdatesMode(cards, open)
}
}
detail?.let { id ->
val from = detailFrom
val icon = StoreState.details[id]?.icon ?: StoreState.sections.values.flatten().firstOrNull { it.id == id }?.icon ?: FlathubApi.iconUrl(id)
val body: @Composable () -> Unit = {
Column(
Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } }
.verticalScroll(rememberScrollState()).padding(bottom = 16.dp).focusGroup(),
) { AppDetail(s, a, id) { openApp = null } }
}
if (from == null) body()
else {
// The card's own icon, cropped over the tile as it grows: the page arrives in the app's colour.
val art = coil.compose.rememberAsyncImagePainter(
remember(icon) { coil.request.ImageRequest.Builder(ctx).data(icon).size(256, 256).build() },
)
PageFlood(from, leaving, art) { body() }
}
}
}
}
}
/**
* Discover, Installed (with its count) and Updates (when there are any): one selection glides under
* them in teal; the Installed count pops as an install's dot lands in it.
*/
@Composable
private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
private fun FlathubChips(mode: String, modes: List<String>, installed: Int, updates: Int, onPick: (String) -> Unit) {
val pal = LocalPalette.current
val apps = StoreState.installed
Rise(3) {
Actions {
SecondaryButton(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) {
StoreState.checkUpdates(ctx)
}
if (StoreState.updates.isNotEmpty()) PrimaryButton(stringResource(R.string.store_update_all, StoreState.updates.size), enabled = StoreState.busy == null) {
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
} else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip(stringResource(R.string.store_all_current), ok = true)
}
}
if (apps.isEmpty()) {
Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) stringResource(R.string.store_no_apps) else stringResource(R.string.store_setup_first_note)) } }
return
}
Rise(4) { SectionTitle(stringResource(R.string.store_tab_installed), apps.size.toString()) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
apps.forEachIndexed { i, app ->
key(app.id) {
Rise(5 + i.coerceAtMost(6)) {
val colors = MaterialTheme.colorScheme
val bounds = remember { mutableStateMapOf<String, Rect>() }
val tint = rememberUpdatedState(pal.signal)
Box(Modifier.fillMaxWidth()) {
ChipGlide(modes.indexOf(mode), bounds[mode], tint)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
for (m in modes) {
key(m) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
val on = m == mode
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
modifier = Modifier.weight(1f).paneItem("flathub:$m")
.onGloballyPositioned {
bounds[m] = it.boundsInParent()
if (m == MODE_INSTALLED) FlathubMotion.installedChip = it.boundsInRoot()
}
.heightIn(min = 44.dp).clip(shape)
.background(if (on) Color.Transparent else colors.surface)
.glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab) { onPick(m) }
.controllerConfirm { onPick(m) }
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44)
Column(modifier = Modifier.weight(1f)) {
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
(app.summary ?: app.id).let { if (app.id in StoreState.updates) stringResource(R.string.store_update_available_prefix, it) else it },
fontSize = 13.sp, color = if (app.id in StoreState.updates) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
PrimaryButton(stringResource(R.string.store_open), compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) }
if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) }
SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) }
Text(
stringResource(when (m) { MODE_DISCOVER -> R.string.store_tab_discover; MODE_INSTALLED -> R.string.store_tab_installed; else -> R.string.store_updates }),
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1,
)
val count = when (m) { MODE_INSTALLED -> installed; MODE_UPDATES -> updates; else -> -1 }
if (count >= 0) Text(
count.toString(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
modifier = Modifier.then(if (m == MODE_INSTALLED) Modifier.landingPop(FlathubMotion.landed) else Modifier)
.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 6.dp, vertical = 1.dp),
)
}
}
}
@@ -285,8 +431,202 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
}
}
private fun s_busyFor(id: String) = StoreState.busy == id
/**
* The card width for a width: room for a name on two lines and a two-line summary under the art,
* so a long name wraps instead of being cut to a few letters.
*/
private fun cardSize(width: Dp): Dp = when {
width >= 700.dp -> 164.dp
width >= 480.dp -> 148.dp
else -> 132.dp
}
@Composable
private fun Discover(
query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String) -> Unit,
cards: HashMap<String, Rect>, onOpen: (String) -> Unit,
) {
Rise(1) {
ModeHeader(
stringResource(R.string.store_tab_discover), query, onQuery, stringResource(R.string.store_search_placeholder),
) {
// The categories as small pills, at the right of the header.
val pills = rememberScrollState()
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.widthIn(max = 420.dp).edgeFade(pills).horizontalScroll(pills),
) {
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id) } }
}
}
}
val results = StoreState.searchResults
if (query.isNotBlank() || category != null) {
when {
StoreState.searching && results == null -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_searching)) } }
results.isNullOrEmpty() -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_results)) } }
else -> Rise(2) { CardGrid(results.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, cards, onOpen) }
}
return
}
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_unreachable)) } }
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } }
return
}
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
val apps = StoreState.sections[key]
Rise(2 + i) { Shelf(stringResource(title), apps?.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, first = i == 0, cards, onOpen) }
}
}
@Composable
private fun InstalledMode(cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
val ctx = LocalContext.current
val apps = StoreState.installed
Rise(1) {
ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_tab_installed), apps.size), "", {}, "") {
SmallLink(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = !StoreState.checkingUpdates && StoreState.busy == null) {
StoreState.checkUpdates(ctx)
}
}
}
if (apps.isEmpty()) { Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_apps)) } }; return }
Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) }
}
@Composable
private fun UpdatesMode(cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
val ctx = LocalContext.current
val apps = StoreState.installed.filter { it.id in StoreState.updates }
Rise(1) {
ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_updates), apps.size), "", {}, "") {
PrimaryButton(stringResource(R.string.store_update_all, apps.size), compact = true, enabled = StoreState.busy == null) {
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
}
}
}
Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) }
}
/** A horizontal row of cards under a title, the stores' shelf; nothing while it is empty. */
@Composable
private fun Shelf(title: String, apps: List<CardApp>?, first: Boolean, cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
Column(Modifier.fillMaxWidth()) {
Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 14.dp, bottom = 6.dp))
if (apps == null) Text(stringResource(R.string.store_section_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
else if (apps.isNotEmpty()) BoxWithConstraints(Modifier.fillMaxWidth()) {
val size = cardSize(maxWidth)
val shelf = rememberScrollState()
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().edgeFade(shelf).horizontalScroll(shelf).padding(vertical = 2.dp)) {
apps.take(18).forEachIndexed { i, app -> key(app.id) { AppCard(app, size, first && i == 0, cards) { onOpen(app.id) } } }
}
}
}
}
/** The Installed and Updates modes, and search: a grid of the same cards. */
@Composable
private fun CardGrid(apps: List<CardApp>, cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 10.dp)) {
val size = cardSize(maxWidth)
val columns = ((maxWidth + 10.dp) / (size + 10.dp)).toInt().coerceAtLeast(1)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
apps.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEachIndexed { i, app -> key(app.id) { AppCard(app, size, r == 0 && i == 0, cards) { onOpen(app.id) } } }
}
}
}
}
}
/**
* One app: its icon over a blurred, darkened copy of itself (darkened only, before Android 12),
* with one status on the art - Get, ✓, or how far an install is - and under it the name, on two
* lines if it needs them, and what the app is.
*/
@Composable
private fun AppCard(app: CardApp, size: Dp, first: Boolean, cards: HashMap<String, Rect>, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
val shape = RoundedCornerShape(16.dp)
val installed = StoreState.installed.any { it.id == app.id }
val update = app.id in StoreState.updates
val busy = StoreState.busy == app.id
val artH = size * 0.62f
Column(
Modifier.width(size).paneItem("tile:store:${app.id}").then(if (first) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = app.name },
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { cards[app.id] = it.boundsInRoot() }
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(Color(0xFF0B2E2B)),
) {
if (app.icon != null) {
// The icon's own colour, blurred and darkened, as the card's ground.
AsyncImage(
model = app.icon, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.55f }
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
AsyncImage(model = app.icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.62f))
} else Icon(Icons.Outlined.Apps, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.5f))
// One status, on the art: what A on the card leads to.
Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) {
when {
busy -> Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) {
if (StoreState.percent >= 0) CircularProgressIndicator(progress = { StoreState.percent / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp))
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
}
else -> Text(
when {
update -> stringResource(R.string.common_update)
installed -> "✓"
else -> stringResource(R.string.comp_get)
},
fontSize = 11.sp, fontWeight = FontWeight.Bold, maxLines = 1,
color = if (installed && !update) FlathubInk else pal.onSignal,
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(if (installed && !update) pal.good else pal.signal)
.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
}
Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
app.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground,
maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
)
if (app.verified) Icon(Icons.Outlined.Verified, stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.padding(top = 2.dp).size(13.dp))
}
// Two lines kept for the summary even when it is short, so cards in a row line up.
Text(
app.summary.orEmpty(), fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant,
minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp),
)
}
}
}
/**
* An app's page, flooded out of its card: its icon, name and what it is, one action (Install or
* Open, with Update beside it when there is one) and Website; then its screenshots and details.
* Install flies a dot to the Installed chip; Remove asks by stepping out of its button, in red.
*/
@Composable
private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: () -> Unit) {
val ctx = LocalContext.current
@@ -296,44 +636,53 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
val d = StoreState.details[id]
val local = StoreState.installed.firstOrNull { it.id == id }
val name = d?.name ?: local?.name ?: id
Rise(0) { BackLink(stringResource(R.string.store_title), onClick = onBack) }
val busyHere = StoreState.busy == id
val installed = local != null
Rise(0) { BackLink(stringResource(R.string.flathub_name), onClick = onBack) }
Rise(1) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) {
AppIcon(d?.icon ?: local?.icon ?: FlathubApi.iconUrl(id), name, 72)
Column(modifier = Modifier.weight(1f)) {
Text(name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
d?.developer?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) }
val meta = listOfNotNull(
d?.developer, d?.version?.let { "v$it" },
d?.downloadSize?.takeIf { it > 0 }?.let { stringResource(R.string.store_download_size, FileUtils.sizeToString(it)) },
).joinToString(" · ")
if (meta.isNotEmpty()) Text(meta, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
(d?.summary ?: local?.summary)?.let { Text(it, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) }
}
}
}
val busyHere = StoreState.busy == id
val installed = local != null
// Remove asks twice: one stray press of A should not cost a download. The second press has
// to come within a few seconds; the app's own data in ~/.var/app stays either way.
var confirmRemove by remember(id) { mutableStateOf(false) }
LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
Rise(2) {
Actions {
if (installed) {
PrimaryButton(stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
PrimaryButton(if (busyHere) stringResource(R.string.common_working) else stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) }
SecondaryButton(if (busyHere) stringResource(R.string.common_working) else if (confirmRemove) stringResource(R.string.store_press_again) else stringResource(R.string.store_remove), enabled = StoreState.busy == null) {
if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true
}
} else {
PrimaryButton(
if (busyHere) stringResource(R.string.store_installing) else stringResource(R.string.store_install), main = true,
enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
) { StoreState.install(ctx, id, name) }
if (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_first), ok = false)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
PrimaryButton(
if (busyHere) (if (StoreState.percent >= 0) "${StoreState.stage ?: stringResource(R.string.store_installing)} · ${StoreState.percent}%" else stringResource(R.string.store_installing))
else stringResource(R.string.store_install),
main = true,
enabled = StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
) {
StoreState.install(ctx, id, name)
// The button squashes into a dot that flies to the Installed chip, whose count pops.
val from = at
if (from != null) Flights.fly(Flight(from, to = { FlathubMotion.installedChip?.center }, onLand = { FlathubMotion.landed++ }))
else FlathubMotion.landed++
}
}
}
d?.version?.let { ActionChip("v$it", ok = false) }
if (d != null && d.downloadSize > 0) ActionChip(stringResource(R.string.store_download_size, FileUtils.sizeToString(d.downloadSize)), ok = false)
if (installed) ActionChip(stringResource(R.string.store_installed_chip), ok = true)
d?.homepage?.let { url ->
SecondaryButton(stringResource(R.string.store_website)) {
runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))) }
}
}
if (installed) RemoveButton(id, name, enabled = StoreState.busy == null)
}
}
if (busyHere) BusyBar()
if (d == null) {
Rise(3) {
Box(Modifier.padding(top = 14.dp)) {
@@ -346,7 +695,8 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
if (d.screenshots.isNotEmpty()) {
Rise(3) { SectionTitle(stringResource(R.string.store_screenshots), null) }
Rise(3) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) {
val shots = rememberScrollState()
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().edgeFade(shots).horizontalScroll(shots)) {
d.screenshots.take(6).forEach { shot ->
AsyncImage(
model = shot.thumb, contentDescription = null, contentScale = ContentScale.Crop,
@@ -379,52 +729,45 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
}
}
/** Apps as wide tiles: icon, name, summary - three across, or a list on a narrow page. */
/** Remove, which asks first: are you sure steps out of it in red, Keep first, so a stray A costs nothing. */
@Composable
private fun AppGrid(apps: List<FlathubApi.AppSummary>, first: Boolean, onOpen: (String) -> Unit) {
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
apps.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, app ->
key(app.id) {
AppTile(app, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onOpen(app.id) }
}
private fun RemoveButton(id: String, name: String, enabled: Boolean) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.store_remove)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 3,
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
) {
StepTitle(ctx.getString(R.string.flathub_remove_title, name))
Text(ctx.getString(R.string.flathub_remove_note), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(2)) {
StepChoice(ctx.getString(R.string.flathub_keep), enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
StepChoice(label, danger = true, enabled = open) { Steps.fold(); StoreState.uninstall(ctx, id, name) }
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
})
}
}
}
/** A small text action at the right of a header. */
@Composable
private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
private fun SmallLink(text: String, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "appScale")
val installed = StoreState.installed.any { it.id == app.id }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:store:${app.id}").then(if (isFirst) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AppIcon(app.icon, app.name, 44)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.size(14.dp))
}
Text(if (installed) stringResource(R.string.store_tab_installed) else app.summary, fontSize = 13.sp, color = if (installed) pal.good else colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
val hot = rememberHot(src) && enabled
Text(
text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (!enabled) colors.onSurfaceVariant.copy(alpha = 0.5f) else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, role = Role.Button, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick).padding(horizontal = 8.dp, vertical = 12.dp),
)
}
/** An app's icon from Flathub (a URL) or from its installed files; a placeholder until it loads. */
@@ -446,13 +789,13 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
Text(
label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.paneItem("pill:$label").clip(shape)
modifier = Modifier.paneItem("pill:$label").heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape)
.background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 9.dp),
.padding(horizontal = 12.dp, vertical = 7.dp),
)
}
@@ -24,6 +24,9 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.material.icons.filled.Settings
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
@@ -490,31 +493,36 @@ internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) {
}
/**
* The chip row's cog: the section's own settings in a small card - which tab a store opens on
* and the speed tier. Setup › Stores repeats them under its gate. Every install is added to Steam;
* that is not a choice.
* The chip row's cog: the section's own settings stepped out of it - which tab a store opens on,
* mature titles, the speed tier and how many at once, and the download engine. Their only home;
* Setup no longer repeats them. Every install is added to Steam; that is not a choice. [s] is
* read live, so a change shows at once in what is stepped out.
*/
@Composable
internal fun StoresSettingsDialog(s: FrontEndState, a: FrontEndActions, onDismiss: () -> Unit) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
AppDialog(shown, close, "storesSettings", wide = false, maxWidth = 440.dp) {
DialogHeader(stringResource(R.string.stores_settings_eyebrow), stringResource(R.string.stores_title))
Rise(1) {
Column {
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
SegmentedTabs(
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
s.storesOpenTab,
) { a.onStoresOpenTab(it) }
}
SettingsRow(stringResource(R.string.stores_show_mature), null) {
ToggleSwitch(s.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) }
}
// The queue's two knobs as one compact row, the same control the Downloads page has.
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(s, a) }
internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<FrontEndState>, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color): StepAsk =
StepAsk(
anchor = at, side = StepSide.Left, accent = accent, items = 3, maxWidth = 440.dp,
handle = { androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Filled.Settings, null, tint = accent, modifier = Modifier.size(20.dp)) },
) {
StepTitle(stringResource(R.string.stores_settings_eyebrow))
Column(
Modifier.stepItem(1).fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val st = s.value
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
SegmentedTabs(
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
st.storesOpenTab,
) { a.onStoresOpenTab(it) }
}
SettingsRow(stringResource(R.string.stores_show_mature), null) {
ToggleSwitch(st.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) }
}
// The queue's two knobs as one compact row, the same control the Downloads page has.
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(st, a) }
StoresEngineRow()
}
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().stepItem(2).padding(top = 8.dp)) {
StepChoice(stringResource(R.string.common_ok), enabled = open, modifier = Modifier.focusRequester(first)) { StoresMotion.fold() }
}
Rise(2) { Actions { SecondaryButton(stringResource(R.string.common_ok), onClick = close) } }
}
}
@@ -83,7 +83,8 @@ internal object StoresMotion {
fun fly(from: Rect) {
if (Motion.scale == 0f || downloadsChip == null) { landed++; return }
flights += Flight(from, SystemClock.uptimeMillis())
// The count sits at the chip's right end.
flights += Flight(from, to = { downloadsChip?.let { Offset(it.right - 22.dp.toPx(), it.center.y) } }, onLand = { landed++ })
}
/** The confirm or list stepping out of a control on the Stores page now, and whether it is open. */
@@ -140,8 +141,6 @@ internal object StoresMotion {
/** A game card at [bounds] (root px) with corner [corner] (px) and its art, for the page it opens to flood out of. */
internal class CardMark(val key: String, val bounds: Rect, val corner: Float, val art: String?)
internal class Flight(val from: Rect, val id: Long)
/**
* A confirm or short list for [StepOut] on the Stores page, asked by a control that sits at [anchor]
* (root px). [content] gets a close that folds it; [onDismiss] runs once it has folded, however.
@@ -153,6 +152,7 @@ internal class StepAsk(
val pillCorner: androidx.compose.ui.unit.Dp? = null,
val items: Int = 4,
val onDismiss: () -> Unit = {},
val maxWidth: androidx.compose.ui.unit.Dp = 300.dp,
val handle: @Composable () -> Unit,
val content: @Composable StepOutScope.() -> Unit,
)
@@ -167,63 +167,21 @@ internal class SignInFlood(val store: Store, val from: Rect, val start: Color, v
/** The dots flying to the Downloads chip, drawn over the page whose root offset is [origin]. */
@Composable
internal fun FlightLayer(origin: Offset) {
for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) }
for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) { StoresMotion.flights.remove(f) } }
}
/**
* One install's dot: the button squashes into a 14dp dot (width first, a little bloop), which then
* flies on an arc to the Downloads chip, stretched along its path like the focus ring's drop.
* Pops (1.3 to 1, with one ring) each time [trigger] changes: by default each time a download's
* dot lands, for the count on the chip and the rail; any other landing passes its own count.
*/
@Composable
private fun FlightDot(f: Flight, origin: Offset) {
val pal = LocalPalette.current
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) {
t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing))
t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing))
StoresMotion.landed++
StoresMotion.flights.remove(f)
}
Canvas(Modifier.fillMaxSize()) {
val r = 7.dp.toPx()
val from = f.from.translate(-origin)
val chip = (StoresMotion.downloadsChip ?: return@Canvas).translate(-origin)
val a = from.center
// The count sits at the chip's right end.
val b = Offset(chip.right - 22.dp.toPx(), chip.center.y)
val v = t.value
if (v < 1f) {
// Squash: the width goes first, then the height, into the dot; a bloop at the end.
val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f))
val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f))
val k = 1f - 0.15f * sin(PI * v).toFloat()
drawRoundRect(pal.signal, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2))
} else {
val p = v - 1f
val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx())
val q = 1f - p
val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y)
val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y))
val stretch = sin(PI * p).toFloat()
val angle = atan2(d.y, d.x) * 180f / PI.toFloat()
rotate(angle, pos) {
val w = 2 * r * (1f + 0.5f * stretch)
val h = 2 * r * (1f - 0.25f * stretch)
drawOval(pal.signal, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h))
}
}
}
}
/** Pops (1.3 to 1, with one ring) each time a download's dot lands; for the count on the chip and the rail. */
internal fun Modifier.landingPop(): Modifier = composed {
internal fun Modifier.landingPop(trigger: Int = StoresMotion.landed): Modifier = composed {
val pal = LocalPalette.current
val scale = remember { Animatable(1f) }
val ring = remember { Animatable(1f) }
val seen = remember { intArrayOf(StoresMotion.landed) }
LaunchedEffect(StoresMotion.landed) {
if (StoresMotion.landed == seen[0]) return@LaunchedEffect
seen[0] = StoresMotion.landed
val seen = remember { intArrayOf(trigger) }
LaunchedEffect(trigger) {
if (trigger == seen[0]) return@LaunchedEffect
seen[0] = trigger
coroutineScope {
launch { scale.snapTo(1.3f); scale.animateTo(1f, Motion.sp(0.45f, 500f)) }
launch { ring.snapTo(0f); ring.animateTo(1f, Motion.tw(400)) }
@@ -160,7 +160,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") }
var query by rememberSaveable { mutableStateOf("") }
var openGame by rememberSaveable { mutableStateOf<String?>(null) }
var settings by rememberSaveable { mutableStateOf(false) }
// The cog's settings read the front end's state live while stepped out.
val liveState = androidx.compose.runtime.rememberUpdatedState(s)
LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } }
val store = Store.byId(chip)
// A tab this store does not have (Store or All on Amazon) reads as Library.
@@ -337,7 +338,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
StoreTheme(Store.byId(focusedChip ?: chip)) {
Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
onFocusChip = { focusedChip = it }, onSettings = { settings = true }) }
onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) }
}
}
val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() }
@@ -475,7 +476,6 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
seenActive.clear(); seenActive.addAll(activeKeys)
if (fresh.isNotEmpty()) StoresMotion.takeInstall()?.let { StoresMotion.fly(it) }
}
if (settings) StoresSettingsDialog(s, a) { settings = false }
StoresState.pendingInstall?.let { item -> InstallWhere(item) }
}
@@ -571,7 +571,7 @@ private fun SignInFloodLayer(hostAt: Offset) {
*/
@Composable
private fun StoreChips(
selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: () -> Unit,
selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit,
/** The chip a pad's focus is on (null when it leaves the row): the page shows that store. */
onFocusChip: (String?) -> Unit,
) {
@@ -621,75 +621,16 @@ private fun StoreChips(
trail = if (active > 0) { { CountPill(active) } } else null,
onFocused = onFocusChip,
) { onDownloads() }
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings), onSettings)
var cogAt by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) {
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) }
}
}
// Over the chips: the fill is translucent, and the other chips' rest fill would hide it in flight.
ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint)
}
}
/**
* The selection under the chips: [at] (in the row) of chip [index], in [tint]. A move to the next
* chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a beat
* later on its trail spring - and a hop past a chip pinches it to a drop, carries it and opens it.
*/
@Composable
private fun BoxScope.ChipGlide(index: Int, at: Rect?, tint: State<Color>) {
val density = androidx.compose.ui.platform.LocalDensity.current
val left = remember { Animatable(Float.NaN) }
val right = remember { Animatable(0f) }
val pinch = remember { Animatable(0f) }
val top = remember { mutableStateOf(0f) }
val bottom = remember { mutableStateOf(0f) }
val was = remember { intArrayOf(-1) }
LaunchedEffect(index, at) {
val b = at ?: return@LaunchedEffect
top.value = b.top; bottom.value = b.bottom
val from = was[0]
was[0] = index
if (left.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) {
left.snapTo(b.left); right.snapTo(b.right); pinch.snapTo(0f)
return@LaunchedEffect
}
coroutineScope {
if (kotlin.math.abs(index - from) > 1) {
// Too far to stretch: pinch into a drop where it is, carry it over, open it there.
pinch.animateTo(1f, Motion.tw(90))
val l = launch { left.animateTo(b.left, Motion.sp(0.7f, 380f)) }
val r = launch { right.animateTo(b.right, Motion.sp(0.7f, 380f)) }
l.join(); r.join()
pinch.animateTo(0f, Motion.sp(0.6f, 500f))
} else {
val forward = index > from
val (lead, trail) = if (forward) right to left else left to right
val leadTo = if (forward) b.right else b.left
val trailTo = if (forward) b.left else b.right
launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) }
launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) }
}
}
}
androidx.compose.foundation.Canvas(Modifier.matchParentSize()) {
if (left.value.isNaN() || bottom.value <= top.value) return@Canvas
val l = minOf(left.value, right.value)
val r = maxOf(left.value, right.value)
val cx = (l + r) / 2f
val cy = (top.value + bottom.value) / 2f
val drop = with(density) { 6.dp.toPx() }
val p = pinch.value
val x0 = androidx.compose.ui.util.lerp(l, cx - drop, p)
val x1 = androidx.compose.ui.util.lerp(r, cx + drop, p)
val y0 = androidx.compose.ui.util.lerp(top.value, cy - drop, p)
val y1 = androidx.compose.ui.util.lerp(bottom.value, cy + drop, p)
val size = androidx.compose.ui.geometry.Size(x1 - x0, y1 - y0)
val corner = androidx.compose.ui.geometry.CornerRadius(minOf(size.width, size.height) / 2f)
val at0 = Offset(x0, y0)
// A drop in flight is solid; on a chip it is the chip's tinted fill and outline.
drawRoundRect(tint.value.copy(alpha = androidx.compose.ui.util.lerp(0.16f, 0.9f, p)), at0, size, corner)
drawRoundRect(tint.value.copy(alpha = 0.7f * (1f - p)), at0, size, corner, style = androidx.compose.ui.graphics.drawscope.Stroke(with(density) { 1.dp.toPx() }))
}
}
@Composable
private fun StoreChip(
label: String, on: Boolean, key: String, modifier: Modifier, lead: @Composable () -> Unit,
@@ -945,7 +886,7 @@ private fun Storefront(
* search field across the row, and [trailing] (the account) at the right, over a rule.
*/
@Composable
private fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// Open while it holds a query; an empty field closes again once focus leaves it.
@@ -1001,6 +942,22 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
)
}
/** The Store section's accent: Flathub's teal, with its dark ink on it. */
internal fun flathubPalette(base: Palette): Palette = Palette(
base.id, base.label, base.detail, base.background, base.surface, base.surfaceVariant, base.line, base.line2,
base.onBackground, base.onSurfaceVariant, primary = FlathubTeal, primary2 = Color(0xFF2E958D), onPrimary = FlathubInk, signal = FlathubTeal,
good = base.good, error = base.error,
)
/** [content] with the focus ring, buttons, step-outs and the chip glide in Flathub's teal. */
@Composable
internal fun FlathubTheme(content: @Composable () -> Unit) {
val base = LocalPalette.current
val p = remember(base) { flathubPalette(base) }
val scheme = MaterialTheme.colorScheme.copy(primary = p.primary, onPrimary = p.onPrimary)
CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, typography = MaterialTheme.typography, content = content) }
}
/** [content] with the focus ring, buttons and step-outs in [store]'s colour. */
@Composable
internal fun StoreTheme(store: Store?, content: @Composable () -> Unit) {
@@ -1060,7 +1017,7 @@ private fun SignOutButton(store: Store, name: String) {
/** The header's magnifier: a round button the search field opens out of. */
@Composable
private fun SearchIcon(description: String, onClick: () -> Unit) {
internal fun SearchIcon(description: String, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
-3
View File
@@ -642,9 +642,6 @@
<string name="setup_choose_home">Elige una app de inicio</string>
<string name="setup_choose">Elegir</string>
<string name="setup_linux_apps">Apps de Linux (beta)</string>
<string name="setup_store">Tienda Flathub</string>
<string name="setup_store_on">En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck.</string>
<string name="setup_store_off">Instala apps y juegos de Linux desde Flathub</string>
<string name="setup_tool_files">Archivos</string>
<string name="setup_tool_files_hint">Explorar y gestionar archivos</string>
<string name="setup_tool_protons">Versiones de Proton</string>
-3
View File
@@ -518,9 +518,6 @@
<string name="setup_choose_home">Choisir l\'application d\'écran d\'accueil</string>
<string name="setup_choose">Choisir</string>
<string name="setup_linux_apps">Applications Linux (bêta)</string>
<string name="setup_store">Boutique Flathub</string>
<string name="setup_store_on">Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck.</string>
<string name="setup_store_off">Installer des applications et des jeux Linux depuis Flathub</string>
<string name="setup_tool_files">Fichiers</string>
<string name="setup_tool_files_hint">Parcourir et gérer les fichiers</string>
<string name="setup_tool_protons">Versions de Proton</string>
-3
View File
@@ -638,9 +638,6 @@
<string name="setup_choose_home">ホームアプリを選択</string>
<string name="setup_choose">選択</string>
<string name="setup_linux_apps">Linuxアプリ(ベータ版)</string>
<string name="setup_store">Flathubストア</string>
<string name="setup_store_on">メニューに表示されます。一部のアプリは起動できない場合があります。ログはDownload/DroidDeckにあります。</string>
<string name="setup_store_off">FlathubからLinuxアプリとゲームをインストール</string>
<string name="setup_tool_files">ファイル</string>
<string name="setup_tool_files_hint">ファイルの閲覧と管理</string>
<string name="setup_tool_protons">Protonバージョン</string>
-3
View File
@@ -638,9 +638,6 @@
<string name="setup_choose_home">홈 앱 선택</string>
<string name="setup_choose">선택</string>
<string name="setup_linux_apps">Linux 앱(베타)</string>
<string name="setup_store">Flathub 스토어</string>
<string name="setup_store_on">메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다. 로그는 Download/DroidDeck에 있습니다.</string>
<string name="setup_store_off">Flathub에서 Linux 앱 및 게임 설치</string>
<string name="setup_tool_files">파일</string>
<string name="setup_tool_files_hint">파일 탐색 및 관리</string>
<string name="setup_tool_protons">Proton 버전</string>
-3
View File
@@ -596,9 +596,6 @@
<string name="setup_choose_home">Выбрать главный экран</string>
<string name="setup_choose">Выбрать</string>
<string name="setup_linux_apps">Приложения Linux (бета)</string>
<string name="setup_store">Магазин Flathub</string>
<string name="setup_store_on">Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck.</string>
<string name="setup_store_off">Устанавливать приложения и игры Linux из Flathub</string>
<string name="setup_tool_files">Файлы</string>
<string name="setup_tool_files_hint">Просмотр и управление файлами</string>
<string name="setup_tool_protons">Версии Proton</string>
@@ -638,9 +638,6 @@
<string name="setup_choose_home">选择主屏幕应用</string>
<string name="setup_choose">选择</string>
<string name="setup_linux_apps">Linux 应用(测试版)</string>
<string name="setup_store">Flathub 商店</string>
<string name="setup_store_on">显示在菜单中。部分应用可能无法启动;日志位于 Download/DroidDeck。</string>
<string name="setup_store_off">从 Flathub 安装 Linux 应用和游戏</string>
<string name="setup_tool_files">文件</string>
<string name="setup_tool_files_hint">浏览和管理文件</string>
<string name="setup_tool_protons">Proton 版本</string>
@@ -638,9 +638,6 @@
<string name="setup_choose_home">選擇主畫面應用程式</string>
<string name="setup_choose">選擇</string>
<string name="setup_linux_apps">Linux 應用程式(測試版)</string>
<string name="setup_store">Flathub 商店</string>
<string name="setup_store_on">顯示在選單中。部分應用程式可能無法啟動;記錄位於 Download/DroidDeck。</string>
<string name="setup_store_off">從 Flathub 安裝 Linux 應用程式和遊戲</string>
<string name="setup_tool_files">檔案</string>
<string name="setup_tool_files_hint">瀏覽及管理檔案</string>
<string name="setup_tool_protons">Proton 版本</string>
@@ -638,9 +638,6 @@
<string name="setup_choose_home">選擇主螢幕應用程式</string>
<string name="setup_choose">選擇</string>
<string name="setup_linux_apps">Linux 應用程式(測試版)</string>
<string name="setup_store">Flathub 商店</string>
<string name="setup_store_on">顯示在選單中。部分應用程式可能無法啟動;日誌位於 Download/DroidDeck。</string>
<string name="setup_store_off">從 Flathub 安裝 Linux 應用程式和遊戲</string>
<string name="setup_tool_files">檔案</string>
<string name="setup_tool_files_hint">瀏覽和管理檔案</string>
<string name="setup_tool_protons">Proton 版本</string>
+46 -4
View File
@@ -658,9 +658,6 @@
<string name="setup_choose_home">Choose a Home app</string>
<string name="setup_choose">Choose</string>
<string name="setup_linux_apps">Linux apps (beta)</string>
<string name="setup_store">Flathub Store</string>
<string name="setup_store_on">In the menu. Some apps may not start; logs are in Download/DroidDeck.</string>
<string name="setup_store_off">Install Linux apps and games from Flathub</string>
<string name="setup_tool_files">Files</string>
<string name="setup_tool_files_hint">Browse and manage files</string>
<string name="setup_tool_protons">Proton versions</string>
@@ -1993,7 +1990,6 @@
<string name="source_steam">Steam</string>
<string name="source_added">Custom</string>
<string name="setup_stores">Stores</string>
<string name="setup_stores_show">Show Stores in the rail</string>
<string name="setup_stores_open_on">Open a store on</string>
<string name="setup_stores_speed">Download speed tier</string>
<string name="setup_stores_engine">Stores engine</string>
@@ -2183,4 +2179,50 @@
<item quantity="one">%1$d already in Games</item>
<item quantity="other">%1$d already in Games</item>
</plurals>
<!-- A page reached through a link from another section: the way back, named for where it came from. -->
<string name="hop_back">B · Back to %1$s</string>
<string name="hop_from_games">the game</string>
<string name="hop_from_play">Play settings</string>
<!-- The lifted settings sheets under Play and Open desktop. -->
<string name="sheet_graphics_drivers">Graphics drivers</string>
<!-- Components as one stack of layers. -->
<string name="comp_this_proton">This Proton</string>
<string name="comp_every_proton">Every Proton</string>
<string name="comp_for_every_proton">For every Proton</string>
<string name="comp_available_count">Available · %1$d</string>
<string name="comp_checked">Checked %1$s</string>
<string name="comp_check_all">Check for new components and drivers</string>
<string name="comp_get">Get</string>
<string name="comp_proton_bar">%1$s · DXVK, VKD3D-Proton and FEX below are this Proton\'s</string>
<string name="comp_device_tested">%1$s · tested</string>
<string name="comp_device_untested">%1$s · untested</string>
<string name="comp_device_unsupported">%1$s · unsupported</string>
<string name="gpu_auto_keeps">Keeps the recommended pair current</string>
<string name="gpu_bar_auto">Auto · %1$s</string>
<string name="gpu_separately">Runtime and display drivers separately</string>
<string name="gpu_use_title">Use %1$s?</string>
<string name="proton_remove_title">Remove %1$s?</string>
<!-- Setup sorted by scope. -->
<string name="setup_tab_diagnostics">Diagnostics</string>
<string name="setup_ready">Ready to play</string>
<string name="setup_not_ready">Not ready to play yet</string>
<string name="setup_ready_runtime">runtime %1$s</string>
<string name="setup_ready_limit_off">process limit off</string>
<string name="setup_ready_signed_in">signed in as %1$s</string>
<string name="setup_needs_you">When something needs you</string>
<string name="setup_group_look">Look</string>
<string name="setup_group_device">This device</string>
<string name="setup_group_art">Library art</string>
<!-- Flathub, in the stores' style. -->
<string name="flathub_eyebrow">LINUX APPS</string>
<string name="flathub_name">Flathub</string>
<string name="flathub_line">Desktop apps for the Linux desktop, opened from Desktop.</string>
<string name="flathub_setup">Set up Flathub</string>
<string name="flathub_remove_title">Remove %1$s?</string>
<string name="flathub_remove_note">Its settings and files are kept.</string>
<string name="flathub_keep">Keep</string>
<!-- Desktop in the Steam tab's shape. -->
<string name="desktop_size">Size on disk</string>
<string name="desktop_uninstall_title">Uninstall the desktop?</string>
<string name="desktop_all_systems">All</string>
</resources>
@@ -0,0 +1,29 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class GlideShapeTest {
@Test fun restingAcrossCoversTheChip() {
val r = glideShape(10f, 90f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false)
assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f)
assertEquals(0f, r.top, 0.01f); assertEquals(40f, r.bottom, 0.01f)
}
@Test fun edgesInEitherOrder() {
val r = glideShape(90f, 10f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false)
assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f)
}
@Test fun pinchedIsATwelveDpDropAtTheCentre() {
val r = glideShape(10f, 90f, 0f, 40f, pinch = 1f, drop = 6f, vertical = false)
assertEquals(12f, r.width, 0.01f); assertEquals(12f, r.height, 0.01f)
assertEquals(50f, r.center.x, 0.01f); assertEquals(20f, r.center.y, 0.01f)
}
@Test fun verticalRunsTheAxisDown() {
val r = glideShape(100f, 160f, 4f, 84f, pinch = 0f, drop = 6f, vertical = true)
assertEquals(4f, r.left, 0.01f); assertEquals(84f, r.right, 0.01f)
assertEquals(100f, r.top, 0.01f); assertEquals(160f, r.bottom, 0.01f)
}
}
@@ -0,0 +1,20 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.geometry.Rect
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class InPaneTest {
@Test fun aRailItemLeftOfThePaneStartsHalfInside() {
val o = inPane(Origin(Rect(-200f, 300f, -20f, 420f), 14f))
assertEquals(-90f, o.bounds.left, 0.01f)
assertEquals(90f, o.bounds.right, 0.01f)
assertEquals(300f, o.bounds.top, 0.01f)
}
@Test fun aControlInThePaneStaysPut() {
val origin = Origin(Rect(40f, 300f, 200f, 360f), 12f)
assertSame(origin, inPane(origin))
}
}
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LayerStepTest {
@Test fun theStackRunsFromTheProtonDownToTheGpuDrivers() {
assertEquals(listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB), LAYERS)
}
@Test fun rbStepsDownAndWraps() {
assertEquals("dxvk", layerStep(PROTON_TAB, 1))
assertEquals(PROTON_TAB, layerStep(GPU_TAB, 1))
}
@Test fun lbStepsUpAndWraps() {
assertEquals("fex", layerStep(GPU_TAB, -1))
assertEquals(GPU_TAB, layerStep(PROTON_TAB, -1))
}
@Test fun anUnknownLayerStartsFromTheTop() {
assertEquals("dxvk", layerStep("tab-from-an-older-build", 1))
}
}
@@ -0,0 +1,18 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LiftSheetTest {
@Test fun theSheetTopIs82dpDownA360dpPane() {
assertEquals(82f, sheetTop(360f, 56f, 16f, 3f), 0.01f)
assertEquals(164f, sheetTop(720f, 56f, 16f, 3f), 0.01f)
}
@Test fun theButtonKeeps16dpAboveIt() {
// A short pane: the edge comes down so the button riding it still clears the top by 16.
val top = sheetTop(150f, 56f, 16f, 3f)
assertEquals(41f, top, 0.01f)
assertEquals(16f, top + 3f - 56f / 2f, 0.01f)
}
}
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class PageExitTest {
@Test fun backToTheSameRailItemDrainsIntoTheControl() {
assertEquals(PageExit.Drain, pageExit("steam", "steam", PageLeave.Ordinary))
}
@Test fun anotherRailItemSinks() {
assertEquals(PageExit.Sink, pageExit("steam", "games", PageLeave.Ordinary))
}
@Test fun anotherPageSinks() {
// The GPU drivers row in Steam's settings opens Components over the same rail item.
assertEquals(PageExit.Sink, pageExit("steam", "components", PageLeave.Ordinary))
}
@Test fun aLinkIntoAnotherSectionHops() {
assertEquals(PageExit.Hop, pageExit("steam", "components", PageLeave.Link))
}
@Test fun drainingWinsWhenTheOwnerComesBackAnyway() {
assertEquals(PageExit.Drain, pageExit("desktop", "desktop", PageLeave.Link))
}
@Test fun aPageWithNoOwnerNeverDrains() {
assertEquals(PageExit.Sink, pageExit(null, "steam", PageLeave.Ordinary))
}
}