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.
This commit is contained in:
The412Banner committed 2026-10-31 01:55:28 +00:00
1 parent a81d1a4d4e
commit 31e6c08be9
9 files changed
+233 -14

No files matched your search

@@ -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<BottomPage> = emptyList()
private fun tapSteamButton() {
if (SessionState.mode == SessionService.MODE_STEAM) {
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
@@ -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
}
}
@@ -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) {
@@ -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>): 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<BottomPage>,
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)
}
}
@@ -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))
@@ -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<SecondScreenDisplay>,
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 ->
+2
View File
@@ -1876,6 +1876,8 @@
<string name="second_mode_keyboard_trackpad">Keyboard + trackpad</string>
<string name="second_mode_terminal">Terminal</string>
<string name="second_mode_deck_controls">Deck grips + trackpads</string>
<string name="second_mode_dashboard">Dashboard</string>
<string name="bottom_dashboard_empty">Nothing to show here yet.</string>
<string name="rtinst_removing">Removing Linux runtime</string>
<plurals name="rtinst_removing_entries">
@@ -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))
}
}
@@ -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))
}
}