diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index efe466287..022c1d9e4 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -120,6 +120,8 @@ import com.droiddeck.launcher.ui.LoadingOverlay import com.droiddeck.launcher.ui.SessionDrawer import com.droiddeck.launcher.ui.SessionPausedOverlay import com.droiddeck.launcher.ui.SecondScreenPresentation +import com.droiddeck.launcher.ui.BottomDashboard +import com.droiddeck.launcher.ui.BottomPage import com.droiddeck.launcher.ui.DroidDeckTheme import com.droiddeck.launcher.wayland.CompositorHost import com.droiddeck.launcher.wayland.WaylandCompositor @@ -1177,6 +1179,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct), fexPreset = fexPreset, secondScreenMode = secondScreenMode, + dashboardAvailable = bottomPages().isNotEmpty(), secondScreenDisplays = secondScreenDisplays, selectedSecondScreenDisplay = selectedSecondScreenDisplay, onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, @@ -3034,7 +3037,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (secondScreenDisplays.none { it.id == target.displayId }) return SessionState.secondScreenAutoOpened = true selectedSecondScreenDisplay = target.displayId - selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad)) + selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty())) dualControlsProvisional = ::loading.isInitialized && loading.visible } @@ -3042,7 +3045,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!dualControlsProvisional || pipUi || !started || (::loading.isInitialized && loading.visible)) return dualControlsProvisional = false if (secondScreenMode == SecondScreenMode.NONE) return - val wanted = SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad) + val wanted = SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty()) if (wanted != secondScreenMode) selectSecondScreenMode(wanted) else secondScreenPresentation?.retryTerminal() } @@ -3112,8 +3115,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { menu = { DroidDeckTheme { SessionMenu(docked = true) } }, + dashboard = if (bottomPages().isEmpty()) null else ({ + DroidDeckTheme { + BottomDashboard( + bottomPages(), SessionPrefs.bottomPage(this), + onPage = { SessionPrefs.setBottomPage(this, it) }, + onClose = { selectSecondScreenMode(SecondScreenMode.NONE) }, + onMenu = if (dockDrawer()) ({ drawerOpen = true }) else null, + ) + } + }), ) + /** Pages for the bottom-screen dashboard; the dashboard is offered only while at least one exists. */ + private fun bottomPages(): List = emptyList() + private fun tapSteamButton() { if (SessionState.mode == SessionService.MODE_STEAM) { padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt index 019ac686c..112d28270 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt @@ -9,11 +9,14 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) { NONE("none", R.string.second_mode_none), KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad), TERMINAL("terminal", R.string.second_mode_terminal), - DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls); + DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls), + DASHBOARD("dashboard", R.string.second_mode_dashboard); companion object { - fun opening(remembered: String?, deckPad: Boolean): SecondScreenMode = - entries.firstOrNull { it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) } ?: if (deckPad) DECK_CONTROLS else KEYBOARD_TRACKPAD + fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false): SecondScreenMode = + entries.firstOrNull { + it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) + } ?: if (deckPad) DECK_CONTROLS else KEYBOARD_TRACKPAD } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 191cd4c0d..1f84c1146 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -100,6 +100,12 @@ object SessionPrefs { prefs(context).edit().putString("secondScreenPanel", mode).apply() } + fun bottomPage(context: Context): String? = prefs(context).getString("bottomPage", null) + + fun setBottomPage(context: Context, page: String) { + prefs(context).edit().putString("bottomPage", page).apply() + } + fun dualScreenLauncher(context: Context): Boolean = prefs(context).getBoolean("dualScreenLauncher", true) fun setDualScreenLauncher(context: Context, on: Boolean) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt new file mode 100644 index 000000000..347c0fe15 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt @@ -0,0 +1,131 @@ +package com.droiddeck.launcher.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +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.sp +import com.droiddeck.launcher.R + +/** A page the bottom screen can show while the game runs on the main screen. */ +class BottomPage( + val id: String, + @StringRes val label: Int, + val icon: ImageVector, + val content: @Composable () -> Unit, +) + +internal fun pickBottomPage(remembered: String?, ids: List): String? = + ids.firstOrNull { it == remembered } ?: ids.firstOrNull() + +/** Tabs show their names while every tab still fits at [TAB_WIDTH]; otherwise only the open tab keeps its name. */ +internal fun bottomTabsWithLabels(widthDp: Float, tabs: Int): Boolean = tabs * TAB_WIDTH + 2 * BAR_BUTTON <= widthDp + +private const val TAB_WIDTH = 132f +private const val BAR_BUTTON = 56f + +@Composable +fun BottomDashboard( + pages: List, + remembered: String?, + onPage: (String) -> Unit, + onClose: () -> Unit, + onMenu: (() -> Unit)?, +) { + val colors = MaterialTheme.colorScheme + var current by remember { mutableStateOf(pickBottomPage(remembered, pages.map { it.id })) } + val page = pages.firstOrNull { it.id == current } ?: pages.firstOrNull() + BoxWithConstraints(Modifier.fillMaxSize().background(colors.background)) { + val labels = bottomTabsWithLabels(maxWidth.value, pages.size) + Column(Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + BarButton(Icons.Outlined.Close, stringResource(R.string.deck_close), "bottom-close", onClose) + Row(Modifier.weight(1f).horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + pages.forEach { p -> + PageTab(p, p.id == page?.id, labels) { + current = p.id + onPage(p.id) + } + } + } + if (onMenu != null) BarButton(Icons.Outlined.Menu, stringResource(R.string.touch_droiddeck_menu), "bottom-menu", onMenu) + } + Box(Modifier.weight(1f).fillMaxWidth()) { + if (page != null) page.content() + else Text(stringResource(R.string.bottom_dashboard_empty), fontSize = 15.sp, color = colors.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center)) + } + } + } +} + +@Composable +private fun BarButton(icon: ImageVector, label: String, tag: String, onClick: () -> Unit) { + val pal = LocalPalette.current + Box( + Modifier.size(44.dp).clip(RoundedCornerShape(22.dp)).background(pal.surface).border(1.dp, pal.line, RoundedCornerShape(22.dp)) + .semantics { contentDescription = label }.clickable(role = Role.Button, onClick = onClick).testTag(tag), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(22.dp)) + } +} + +@Composable +private fun PageTab(page: BottomPage, selected: Boolean, label: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(20.dp) + val name = stringResource(page.label) + Row( + Modifier.height(44.dp).clip(shape) + .background(if (selected) pal.signal else Color.Transparent) + .border(1.dp, if (selected) pal.signal else pal.line, shape) + .semantics { contentDescription = name } + .clickable(role = Role.Tab, onClick = onClick).testTag("bottom-page-${page.id}") + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val tint = if (selected) colors.background else colors.onBackground + Icon(page.icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) + if (label || selected) Text(name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index 3caad9f77..4f0779a22 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -56,9 +56,11 @@ class SecondScreenPresentation( private val onKeyboardClosed: () -> Unit = {}, private val onMenu: (() -> Unit)? = null, private val menu: (@androidx.compose.runtime.Composable () -> Unit)? = null, + private val dashboard: (@androidx.compose.runtime.Composable () -> Unit)? = null, ) : Presentation(sessionActivity, display) { private val frame = FrameLayout(context) private var menuView: androidx.compose.ui.platform.ComposeView? = null + private var dashboardView: androidx.compose.ui.platform.ComposeView? = null var menuShown = false private set private val root = LinearLayout(context).apply { @@ -155,12 +157,7 @@ class SecondScreenPresentation( menuShown = on if (on) { keyboardHost?.takeIf { it.shown }?.hide() - window?.decorView?.let { decor -> - decor.setViewTreeLifecycleOwner(sessionActivity) - decor.setViewTreeViewModelStoreOwner(sessionActivity) - decor.setViewTreeSavedStateRegistryOwner(sessionActivity) - decor.setViewTreeOnBackPressedDispatcherOwner(sessionActivity) - } + ownComposeViews() val view = menuView ?: androidx.compose.ui.platform.ComposeView(context).apply { isFocusable = true isFocusableInTouchMode = true @@ -179,12 +176,22 @@ class SecondScreenPresentation( } } + private fun ownComposeViews() { + window?.decorView?.let { decor -> + decor.setViewTreeLifecycleOwner(sessionActivity) + decor.setViewTreeViewModelStoreOwner(sessionActivity) + decor.setViewTreeSavedStateRegistryOwner(sessionActivity) + decor.setViewTreeOnBackPressedDispatcherOwner(sessionActivity) + } + } + fun focusMenu(): Boolean = menuView?.requestFocus() == true fun dispatchMenuKey(event: KeyEvent): Boolean = menuView?.dispatchKeyEvent(event) == true fun closeControls() { showMenu(false) + closeDashboard() closeTerminal() keyboardHost?.hide() keyboardHost = null @@ -218,19 +225,37 @@ class SecondScreenPresentation( keyboardButton = null keyboardPending = false closeTerminal() + closeDashboard() root.removeAllViews() - val mode = if (currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad) SecondScreenMode.NONE - else currentMode + val mode = when { + currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad -> SecondScreenMode.NONE + currentMode == SecondScreenMode.DASHBOARD && dashboard == null -> SecondScreenMode.NONE + else -> currentMode + } if (mode == SecondScreenMode.NONE) return when (mode) { SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad() SecondScreenMode.TERMINAL -> renderTerminal() SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad, onMenu = onMenu), weightParams()) + SecondScreenMode.DASHBOARD -> renderDashboard() SecondScreenMode.NONE -> Unit } } + private fun renderDashboard() { + val content = dashboard ?: return + ownComposeViews() + val view = androidx.compose.ui.platform.ComposeView(context).apply { setContent { content() } } + dashboardView = view + root.addView(view, weightParams()) + } + + private fun closeDashboard() { + dashboardView?.disposeComposition() + dashboardView = null + } + private fun renderKeyboardAndTrackpad() { val panel = FrameLayout(context) panel.addView(trackpadView(), FrameLayout.LayoutParams(-1, -1)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index c74910bf2..354932863 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.foundation.layout.heightIn import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.Dashboard import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.AutoFixHigh import androidx.compose.material.icons.outlined.ExpandMore @@ -240,6 +241,7 @@ class DrawerActions( val textureAnisotropy: Int? = 0, val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF, val secondScreenMode: SecondScreenMode, + val dashboardAvailable: Boolean = false, val secondScreenDisplays: List, val selectedSecondScreenDisplay: Int, val onHud: (Boolean) -> Unit, @@ -893,6 +895,7 @@ private fun BottomScreenModes(a: DrawerActions, modifier: Modifier, onPick: () - if (com.droiddeck.launcher.session.SessionState.deckPad) add(SecondScreenMode.DECK_CONTROLS to Icons.Outlined.SportsEsports) add(SecondScreenMode.KEYBOARD_TRACKPAD to Icons.Outlined.Keyboard) add(SecondScreenMode.TERMINAL to Icons.Outlined.Terminal) + if (a.dashboardAvailable) add(SecondScreenMode.DASHBOARD to Icons.Outlined.Dashboard) } Row( verticalAlignment = Alignment.CenterVertically, @@ -1190,7 +1193,8 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) + - if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList() + (if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()) + + (if (a.dashboardAvailable) listOf(SecondScreenMode.DASHBOARD) else emptyList()) SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) { Column(Modifier.fillMaxWidth().selectableGroup()) { modes.forEach { mode -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b85334e2f..cd9015c73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1876,6 +1876,8 @@ Keyboard + trackpad Terminal Deck grips + trackpads + Dashboard + Nothing to show here yet. Removing Linux runtime diff --git a/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt index da6b3bec2..c3cdc7da2 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt @@ -23,4 +23,10 @@ class SecondScreenModeTest { assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening("none", deckPad = true)) assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("gone", deckPad = false)) } + + @Test fun theDashboardOpensAgainOnlyWhileItHasPages() { + assertEquals(SecondScreenMode.DASHBOARD, SecondScreenMode.opening("dashboard", deckPad = true, dashboard = true)) + assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening("dashboard", deckPad = true, dashboard = false)) + assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("dashboard", deckPad = false)) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt new file mode 100644 index 000000000..8bdf4013b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt @@ -0,0 +1,26 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BottomDashboardTest { + @Test fun theRememberedPageOpensAgain() { + assertEquals("shots", pickBottomPage("shots", listOf("downloads", "shots"))) + } + + @Test fun aGoneOrMissingPageFallsBackToTheFirst() { + assertEquals("downloads", pickBottomPage("gone", listOf("downloads", "shots"))) + assertEquals("downloads", pickBottomPage(null, listOf("downloads", "shots"))) + assertNull(pickBottomPage("shots", emptyList())) + } + + @Test fun tabNamesShowOnlyWhileEveryTabFits() { + // Retroid Pocket Duo bottom panel: 1280x960 at 320 dpi = 640 dp wide. + assertTrue(bottomTabsWithLabels(640f, 4)) + assertFalse(bottomTabsWithLabels(640f, 5)) + assertFalse(bottomTabsWithLabels(360f, 3)) + } +}