From 31e6c08be99bded00852fd0aa101ccd72c119048 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Sat, 31 Oct 2026 01:55:28 +0000 Subject: [PATCH] feat(dual-screen): Dashboard mode for the bottom screen A new bottom-screen mode that holds pages (downloads, screenshots, telemetry and more come in the next commits). It sizes itself from the bottom display it is shown on, is offered only while it has pages, and follows the existing Dual screen Auto/On/Off setting and display pick. The last page is remembered. --- .../com/droiddeck/launcher/SessionActivity.kt | 20 ++- .../launcher/input/SecondScreenMode.kt | 9 +- .../launcher/session/SessionPrefs.kt | 6 + .../droiddeck/launcher/ui/BottomDashboard.kt | 131 ++++++++++++++++++ .../launcher/ui/SecondScreenPresentation.kt | 41 ++++-- .../droiddeck/launcher/ui/SessionOverlay.kt | 6 +- app/src/main/res/values/strings.xml | 2 + .../launcher/input/SecondScreenModeTest.kt | 6 + .../launcher/ui/BottomDashboardTest.kt | 26 ++++ 9 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt 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)) + } +}