From ca6cfcd1937bbf836b6a37bcc5e12085cae01853 Mon Sep 17 00:00:00 2001 From: SG3xHERO Date: Sat, 10 Oct 2026 18:24:23 +0100 Subject: [PATCH] Unfolded deck controls (#407) --- .../com/droiddeck/launcher/MainActivity.kt | 5 + .../com/droiddeck/launcher/SessionActivity.kt | 92 ++++- .../launcher/session/SessionDisplay.kt | 20 + .../launcher/session/SessionPrefs.kt | 20 + .../launcher/ui/DeckControlsPanel.kt | 372 +++++++++++++++++- .../droiddeck/launcher/ui/GamepadGeometry.kt | 116 ++++++ .../launcher/ui/ModeSettingsDialog.kt | 8 + .../droiddeck/launcher/ui/SessionOverlay.kt | 6 + app/src/main/res/values/strings.xml | 8 + .../launcher/session/SessionDisplayTest.kt | 9 + .../launcher/ui/GamepadGeometryTest.kt | 88 +++++ 11 files changed, 724 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GamepadGeometry.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/GamepadGeometryTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 9d613e521..53b8b7c22 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1254,6 +1254,8 @@ class MainActivity : ComponentActivity() { steamDownloadsInBackground = steamDownloadsInBackground, pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this), pipAutoEnter = pipAutoEnter, + unfoldedControls = if (mode == SessionService.MODE_STEAM && + com.droiddeck.launcher.session.SessionDisplay.foldable(this)) unfoldedControls else null, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, backActionsInverted = backActionsInverted, directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null, @@ -1311,6 +1313,7 @@ class MainActivity : ComponentActivity() { steamDownloadsInBackground = enabled }, onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on }, + onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, onBackActionsInverted = { inverted -> SessionPrefs.setBackActionsInverted(this, inverted) @@ -1465,9 +1468,11 @@ class MainActivity : ComponentActivity() { } private var pipAutoEnter by mutableStateOf(false) + private var unfoldedControls by mutableStateOf(false) private fun openModeSettings(mode: String) { pipAutoEnter = SessionPrefs.pipAutoEnter(this) + unfoldedControls = SessionPrefs.unfoldedControls(this) showPerformance = false showProtons = false showComponents = false diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 0ee7ff901..568846abd 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -19,6 +19,7 @@ import android.view.MotionEvent import android.view.ViewConfiguration import android.view.Display import android.view.SurfaceHolder +import android.view.Gravity import android.view.SurfaceView import android.view.View import android.view.WindowManager @@ -109,6 +110,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private lateinit var pip: com.droiddeck.launcher.session.SessionPipController private var pipUi by mutableStateOf(false) private var pipAutoEnter by mutableStateOf(false) + /** Unfolded controls: the setting, and the Deck controls under the game while they show. */ + private var unfoldedControls by mutableStateOf(false) + private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null + private lateinit var sessionRoot: FrameLayout private val guestKeysDown = mutableSetOf() private lateinit var surfaceView: SurfaceView private lateinit var sessionOverlay: ComposeView @@ -119,7 +124,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var padMotion: com.droiddeck.launcher.input.PadMotion? = null /** Between onResume and onPause. */ private var resumed = false - private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } } + private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } } /** * A Deck's controller is there from the moment it boots, and the client looks for it once, as @@ -370,9 +375,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off") } } + sessionRoot = root surfaceView = SurfaceView(this) surfaceView.holder.addCallback(this) root.addView(surfaceView) + // The window's size is only known once laid out, and changes as the phone folds or turns. + root.addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob -> + if (r - l != or - ol || b - t != ob - ot) uiHandler.post { applyUnfoldedControls() } + } val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input")) padBridge = bridge @@ -620,6 +630,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { pipAutoEnter = on pip.refresh(true) }, + unfoldedControls = if (com.droiddeck.launcher.session.SessionDisplay.foldable(this@SessionActivity)) unfoldedControls else null, + onUnfoldedControls = ::chooseUnfoldedControls, onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) }, onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() }, onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() }, @@ -800,6 +812,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun readPrefs() { pipAutoEnter = SessionPrefs.pipAutoEnter(this) + unfoldedControls = SessionPrefs.unfoldedControls(this) hudOn = SessionPrefs.hudEnabled(this) upscaler = SessionPrefs.upscaler(this) upscaleSharpness = SessionPrefs.upscaleSharpness(this) @@ -1125,6 +1138,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) applyFollowOrientation(newConfig) + applyUnfoldedControls() } override fun surfaceDestroyed(holder: SurfaceHolder) { @@ -1682,17 +1696,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0) }, sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) }, - onSteamMenu = { - if (SessionState.mode == SessionService.MODE_STEAM) { - padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) } - Handler(Looper.getMainLooper()).postDelayed({ - padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) } - }, 90) - } - }, - onQam = { - if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam() - }, + onSteamMenu = ::tapSteamButton, + onQam = ::tapQam, onClose = { selectSecondScreenMode(SecondScreenMode.NONE) }, ) secondScreenPresentation = presentation @@ -1709,6 +1714,64 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } + private fun tapSteamButton() { + if (SessionState.mode == SessionService.MODE_STEAM) { + padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) } + Handler(Looper.getMainLooper()).postDelayed({ + padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) } + }, 90) + } + } + + private fun tapQam() { + if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam() + } + + private fun chooseUnfoldedControls(on: Boolean) { + SessionPrefs.setUnfoldedControls(this, on) + unfoldedControls = on + applyUnfoldedControls() + } + + /** + * Unfolded controls: on a foldable's inner screen, the game above and the Steam Deck controller's + * trackpads and back grips below ([com.droiddeck.launcher.ui.DeckControlsPanel], as a second + * screen shows them, plus a whole gamepad). The on-screen pad is hidden meanwhile. The surface shrinks to the top part, so a Follow screen session resizes + * to it and any other is fitted into it. Folded onto the cover screen - a phone-sized panel - + * or in picture-in-picture, the game is full screen again. + */ + private fun applyUnfoldedControls() { + if (!::sessionRoot.isInitialized) return + val width = sessionRoot.width + val height = sessionRoot.height + val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) && + SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad && + !pipUi && !isInPictureInPictureMode && width > 0 && height > 0 && + resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP + val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height) + else FrameLayout.LayoutParams.MATCH_PARENT + val params = surfaceView.layoutParams as FrameLayout.LayoutParams + if (params.height != gameHeight) { + surfaceView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, gameHeight, Gravity.TOP) + Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}" + else "unfolded controls: game full screen") + } + var panel = unfoldedPanel + if (!wanted) { + if (panel != null) { sessionRoot.removeView(panel); unfoldedPanel = null; updateOnScreenControls() } + return + } + if (panel == null) { + panel = com.droiddeck.launcher.ui.DeckControlsPanel(this, onClose = { chooseUnfoldedControls(false) }, + onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge) + // Just above the surface: the on-screen pad, keyboard and drawer still draw over it. + sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1) + unfoldedPanel = panel + updateOnScreenControls() + } + panel.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM) + } + private fun moveSecondScreenPointer(x: Float, y: Float, width: Float, height: Float) { if (width <= 0f || height <= 0f) return val nx = (x / width).coerceIn(0f, 1f) @@ -1936,7 +1999,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ?.let { FileUtils.readString(it)?.trim()?.lowercase() } ?: SessionPrefs.oscMode(this) val controls = onScreenControls ?: return - if (pipUi) { + // In picture-in-picture, or with the unfolded screen's gamepad below the game, the on-screen + // pad would only cover the picture or that panel. + if (pipUi || unfoldedPanel != null) { onScreenButtonsVisible = false controls.releaseAll() controls.visibility = View.GONE @@ -2056,6 +2121,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (::pip.isInitialized) pip.changed(inPip) // The panel may have changed while the session was a thumbnail (folded or unfolded in PiP). if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH) + applyUnfoldedControls() } override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt index bae8d79db..bdb557539 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt @@ -52,6 +52,26 @@ object SessionDisplay { fun followSize(width: Int, height: Int): Pair? = if (minOf(width, height) < FOLLOW_MIN_SIDE) null else (width and 1.inv()) to (height and 1.inv()) + /** Unfolded controls: the most of the window the game may take, so a window near 16:9 itself + * still leaves the Deck controls room. */ + const val UNFOLDED_MAX_GAME_SHARE = 0.8f + + /** A foldable: Android 11's hinge angle sensor, which foldables report and slates do not. Unfolded + * controls are offered only there - untested on tablets, whose 16:10 landscape leaves them + * little room under a 16:9 game. */ + fun foldable(context: Context): Boolean = + context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle") + + /** A panel at least this wide on its shortest side (dp) is a foldable's unfolded inner screen. */ + const val UNFOLDED_MIN_DP = 600 + + /** Unfolded controls: the game's height in a window of this size - 16:9 across the full width, + * held either way, with even sides - and the Deck controls take the rest below it, laid out to + * whatever that leaves (DeckControlsPanel's GamepadLayout). */ + fun unfoldedGameHeight(width: Int, height: Int): Int = + minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv() + + fun panelSize(context: Context): Pair { val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager @Suppress("DEPRECATION") 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 87537953a..c1657830b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -883,6 +883,26 @@ object SessionPrefs { prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply() } + /** A foldable's unfolded panel shows the game above the Deck controls (SessionDisplay.unfoldedGameHeight). */ + fun unfoldedControls(context: Context): Boolean = prefs(context).getBoolean("unfoldedControls", false) + + fun setUnfoldedControls(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean("unfoldedControls", enabled).apply() + } + + const val SURFACE_STICK = "stick" + const val SURFACE_TRACKPAD = "trackpad" + + /** The Deck controls' gamepad tab: what each side is - an analog stick or a Steam trackpad. + * Left a stick and right a trackpad until changed, as a Deck is mostly played. */ + fun gamepadSurface(context: Context, right: Boolean): String = + prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null) + ?: if (right) SURFACE_TRACKPAD else SURFACE_STICK + + fun setGamepadSurface(context: Context, right: Boolean, surface: String) { + prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply() + } + fun suspendPolicy(context: Context, mode: String): String = prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL) ?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt index 07917d3f7..4bee3b6a8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt @@ -15,10 +15,16 @@ import android.view.Gravity import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View +import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import com.droiddeck.launcher.input.DeckControls +import com.droiddeck.launcher.input.PadBridge +import com.droiddeck.launcher.input.PadState +import com.droiddeck.launcher.session.SessionPrefs +import kotlin.math.atan2 +import kotlin.math.hypot import kotlin.math.min /** @@ -26,7 +32,8 @@ import kotlin.math.min * * Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a * Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a - * row beneath. A trackpad clicks from its bar or from a second finger while the first is down. + * row beneath. Given the [pad] (the unfolded screen's panel), a fifth: a whole gamepad, each side + * an analog stick or a trackpad as the player picks. A trackpad clicks from its bar or from a second finger while the first is down. * Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only * where something is being touched. */ @@ -36,18 +43,29 @@ class DeckControlsPanel( private val onClose: () -> Unit, private val onSteamMenu: () -> Unit, private val onQam: () -> Unit, + private val pad: PadBridge? = null, ) : LinearLayout(context) { - private enum class Tab(val label: Int) { GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) } + private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) } private val content = FrameLayout(context) private val tabViews = HashMap() + private val topBar: View by lazy { topBar() } init { orientation = VERTICAL setBackgroundColor(Color.BLACK) - addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64))) + addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(64))) addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) - select(selected) + select(if (pad != null) selectedWithPad else selected) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + // Held flat the gamepad has no Panels button, so another tab would have no way back: + // turning the phone from upright onto its side returns to the gamepad. + if (pad != null && selectedWithPad != Tab.GAMEPAD && !GamepadGeometry.tall(w.toFloat(), h.toFloat())) { + post { if (selectedWithPad != Tab.GAMEPAD) select(Tab.GAMEPAD) } + } } override fun onDetachedFromWindow() { @@ -65,6 +83,7 @@ class DeckControlsPanel( background = outline(CORNER_PILL, SURFACE) setPadding(dp(4), dp(4), dp(4), dp(4)) for (tab in Tab.values()) { + if (tab == Tab.GAMEPAD && pad == null) continue val view = TextView(context).apply { text = context.getString(tab.label) textSize = 14f @@ -88,7 +107,7 @@ class DeckControlsPanel( } private fun select(tab: Tab) { - selected = tab + if (pad != null) selectedWithPad = tab else selected = tab DeckControls.releaseAll() for ((each, view) in tabViews) { val on = each == tab @@ -96,12 +115,15 @@ class DeckControlsPanel( view.setTextColor(if (on) ON_ACCENT else TEXT_DIM) } content.removeAllViews() + // The gamepad needs every row of the panel; held upright its Panels button brings the tabs back. + topBar.visibility = if (tab == Tab.GAMEPAD) View.GONE else View.VISIBLE content.addView( when (tab) { Tab.GRIPS -> gripsGrid() Tab.LEFT -> singlePad(right = false) Tab.RIGHT -> singlePad(right = true) Tab.BOTH -> bothPads() + Tab.GAMEPAD -> gamepad(pad ?: return) }, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT), ) @@ -151,6 +173,79 @@ class DeckControlsPanel( addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84))) } + /** + * A whole pad, placed for two thumbs rather than in columns (see [GamepadGeometry]): on each side + * a surface where the thumb rests - most of play is on the sticks or trackpads - and a short + * slide inward along its arc, the d-pad on the left and the face buttons on the right; the bumpers and triggers above, at the top of the arc; View, Steam, QAM and Menu in the + * middle, which a thumb only reaches on purpose. Each surface is an analog stick or a Steam + * trackpad, switched from its corner and remembered (SessionPrefs.gamepadSurface). Its tab bar + * is hidden; held upright, a Panels button brings the other tabs back, and held flat there is no + * room for one. + */ + private fun gamepad(pad: PadBridge): View = GamepadLayout(context).apply { + fun pill(label: String, write: (PadState, Boolean) -> Unit) = PadButton(context, pad, label, write) + place(surfaceSlot(pad, right = false), GamepadGeometry.Slot.LEFT_MAIN) + place(DirectionPad(context, pad), GamepadGeometry.Slot.LEFT_SECOND) + place(surfaceSlot(pad, right = true), GamepadGeometry.Slot.RIGHT_MAIN) + place(FaceButtons(context, pad), GamepadGeometry.Slot.RIGHT_SECOND) + place(pill("LT") { st, d -> st.leftTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER) + place(pill("LB") { st, d -> st.press(PadState.LB, d) }, GamepadGeometry.Slot.LEFT_INNER_SHOULDER) + place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER) + place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER) + place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW) + place(centreTap("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu), GamepadGeometry.Slot.STEAM) + place(centreTap("\u2022\u2022\u2022", context.getString(R.string.drawer_open_qam), onQam), GamepadGeometry.Slot.QAM) + place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU) + place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) }, + GamepadGeometry.Slot.PANELS) + } + + private fun centreTap(label: String, description: String, onClick: () -> Unit) = TextView(context).apply { + text = label + contentDescription = description + textSize = 13f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + letterSpacing = 0.08f + gravity = Gravity.CENTER + setTextColor(TEXT) + background = outline(CORNER_PILL, SURFACE) + setOnClickListener { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onClick() + } + } + + /** One side's stick or trackpad, with a chip in its outer corner that swaps the two. */ + private fun surfaceSlot(pad: PadBridge, right: Boolean): View = FrameLayout(context).apply { + val slot = this + fun show() { + slot.removeAllViews() + val stick = SessionPrefs.gamepadSurface(context, right) == SessionPrefs.SURFACE_STICK + // No caption: the switch in its corner says which it is. + slot.addView(if (stick) StickView(context, pad, right) else TrackpadView(context, right, captioned = false), + FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + val chip = TextView(context).apply { + text = "\u21c4" + contentDescription = context.getString(R.string.deck_surface_switch) + textSize = 14f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + gravity = Gravity.CENTER + setTextColor(TEXT_DIM) + background = outline(CORNER_PILL, SURFACE) + setOnClickListener { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + SessionPrefs.setGamepadSurface(context, right, + if (stick) SessionPrefs.SURFACE_TRACKPAD else SessionPrefs.SURFACE_STICK) + show() + } + } + // In the corner away from the thumb, so play does not swap it by accident. + slot.addView(chip, FrameLayout.LayoutParams(dp(30), dp(30), + (if (right) Gravity.START else Gravity.END) or Gravity.TOP).apply { setMargins(dp(6), dp(6), dp(6), dp(6)) }) + } + show() + } + private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) = TextView(context).apply { text = label @@ -262,7 +357,7 @@ class DeckControlsPanel( * One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click * while a second finger is down. */ - private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) { + private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) { private var touching = false private var clicked = false private var fingerX = 0f @@ -283,7 +378,7 @@ class DeckControlsPanel( canvas.drawLine(cx, cy - reach, cx, cy + reach, guide) captionPaint.textSize = 12 * density captionPaint.color = if (touching) ACCENT else TEXT_DIM - canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint) + if (captioned) canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint) if (touching) { val radius = 56 * density glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP) @@ -333,9 +428,271 @@ class DeckControlsPanel( } } + /** A pad button held while a finger is on it, written into the shared pad as the on-screen pad does. */ + private class PadButton( + context: Context, + private val pad: PadBridge, + label: String, + private val write: (PadState, Boolean) -> Unit, + ) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) { + override fun onDetachedFromWindow() { + pad.applyTouch { write(it, false) } + super.onDetachedFromWindow() + } + } + + /** + * An analog stick that centres where the thumb lands, so it is never missed: the knob follows + * the finger from there out to the ring. Tapping it again and holding (within [CLICK_MS]) is + * the stick's click - L3 or R3 - for as long as the thumb stays down. + */ + private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) { + private var touching = false + private var clicking = false + private var lastUp = 0L + private var originX = 0f + private var originY = 0f + private var knobX = 0f + private var knobY = 0f + private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val knob = Paint(Paint.ANTI_ALIAS_FLAG) + + private fun reach() = min(width, height) * 0.30f + + override fun onDraw(canvas: Canvas) { + drawSurface(canvas, clicking) + val cx = if (touching) originX else width / 2f + val cy = if (touching) originY else height / 2f + ring.strokeWidth = 2 * density + ring.color = if (touching) ACCENT else LINE + canvas.drawCircle(cx, cy, reach(), ring) + knob.color = if (touching) ACCENT else KNOB + canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + val r = reach().coerceAtLeast(1f) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + // Centred where the thumb lands, but kept far enough in for the ring to fit. + originX = event.x.coerceIn(r, (width - r).coerceAtLeast(r)) + originY = event.y.coerceIn(r, (height - r).coerceAtLeast(r)) + touching = true + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + if (event.eventTime - lastUp < CLICK_MS) setClick(true) + follow(event.x, event.y, r) + } + MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + touching = false + lastUp = event.eventTime + setClick(false) + move(0f, 0f, 1f) + } + } + return true + } + + private fun follow(x: Float, y: Float, r: Float) { + var dx = x - originX + var dy = y - originY + val length = hypot(dx, dy) + if (length > r) { dx *= r / length; dy *= r / length } + move(dx, dy, r) + } + + private fun move(dx: Float, dy: Float, r: Float) { + knobX = dx + knobY = dy + pad.applyTouch { s -> + if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r } + } + invalidate() + } + + private fun setClick(down: Boolean) { + if (clicking == down) return + clicking = down + if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) } + } + + override fun onDetachedFromWindow() { + setClick(false) + if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f) + super.onDetachedFromWindow() + } + } + + /** The d-pad: the direction is the finger's angle from the centre, diagonals included. */ + private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) { + private val density = resources.displayMetrics.density + private var held = 0 // bits: 1 up, 2 right, 4 down, 8 left + private val arm = Paint(Paint.ANTI_ALIAS_FLAG) + private val rect = RectF() + + override fun onDraw(canvas: Canvas) { + val size = min(width, height) * 0.86f + val cx = width / 2f + val cy = height / 2f + val half = size / 2f + val thick = size / 3f / 2f + fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) { + arm.color = if (held and bit != 0) ACCENT else KNOB + rect.set(l, t, r, b) + canvas.drawRoundRect(rect, 8 * density, 8 * density, arm) + } + armRect(1, cx - thick, cy - half, cx + thick, cy - thick) + armRect(4, cx - thick, cy + thick, cx + thick, cy + half) + armRect(8, cx - half, cy - thick, cx - thick, cy + thick) + armRect(2, cx + thick, cy - thick, cx + half, cy + thick) + arm.color = KNOB + canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + val bits = when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + val dx = event.getX(0) - width / 2f + val dy = event.getY(0) - height / 2f + if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else { + // Eight sectors of 45 degrees, starting with right and going clockwise. + val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt() + intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector] + } + } + else -> 0 + } + set(bits) + return true + } + + private fun set(bits: Int) { + if (bits == held) return + if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + held = bits + pad.applyTouch { s -> + s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0 + } + invalidate() + } + + override fun onDetachedFromWindow() { + set(0) + super.onDetachedFromWindow() + } + } + + /** A, B, X and Y in a diamond, as on a Deck; several fingers can hold several at once. */ + private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) { + private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X") + private var held = emptySet() + private val fill = Paint(Paint.ANTI_ALIAS_FLAG) + private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + + /** Top, right, bottom, left - the centre of each, and the radius of all. */ + private fun layout(): Pair>, Float> { + val cx = width / 2f + val cy = height / 2f + val spread = min(width, height) * 0.30f + val radius = min(width, height) * 0.16f + return listOf(cx to cy - spread, cx + spread to cy, cx to cy + spread, cx - spread to cy) to radius + } + + override fun onDraw(canvas: Canvas) { + val (centres, radius) = layout() + text.textSize = radius * 0.9f + buttons.forEachIndexed { i, (button, label) -> + val (x, y) = centres[i] + val down = button in held + fill.color = if (down) ACCENT else KNOB + canvas.drawCircle(x, y, radius, fill) + text.color = if (down) ON_ACCENT else TEXT + canvas.drawText(label, x, y + text.textSize * 0.35f, text) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + val (centres, radius) = layout() + val now = HashSet() + val ending = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL + if (!ending) for (p in 0 until event.pointerCount) { + if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && p == event.actionIndex) continue + val x = event.getX(p) + val y = event.getY(p) + // The nearest button within reach, so a thumb between two still lands on one. + val nearest = centres.indices.minByOrNull { hypot(centres[it].first - x, centres[it].second - y) } ?: continue + if (hypot(centres[nearest].first - x, centres[nearest].second - y) <= radius * 1.6f) now.add(buttons[nearest].first) + } + set(now) + return true + } + + private fun set(now: Set) { + if (now == held) return + if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + val before = held + held = now + pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) } + invalidate() + } + + override fun onDetachedFromWindow() { + set(emptySet()) + super.onDetachedFromWindow() + } + } + + /** Lays its controls out where [GamepadGeometry] puts them for the panel's size. */ + private class GamepadLayout(context: Context) : ViewGroup(context) { + private val slots = LinkedHashMap() + + fun place(view: View, slot: GamepadGeometry.Slot) { + slots[view] = slot + addView(view) + } + + private fun boxes(w: Int, h: Int) = GamepadGeometry.layout(w.toFloat(), h.toFloat(), resources.displayMetrics.density) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val w = MeasureSpec.getSize(widthMeasureSpec) + val h = MeasureSpec.getSize(heightMeasureSpec) + setMeasuredDimension(w, h) + val boxes = boxes(w, h) + for ((view, slot) in slots) { + val box = boxes[slot] + view.visibility = if (box == null) View.GONE else View.VISIBLE + if (box == null) continue + view.measure(MeasureSpec.makeMeasureSpec(box.width.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(box.height.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY)) + } + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val boxes = boxes(r - l, b - t) + for ((view, slot) in slots) { + val box = boxes[slot] ?: continue + val left = box.left.toInt() + val top = box.top.toInt() + view.layout(left, top, left + box.width.toInt().coerceAtLeast(1), top + box.height.toInt().coerceAtLeast(1)) + } + } + } + companion object { + /** A second press on a stick this soon after the last is its click (L3, R3). */ + private const val CLICK_MS = 300L + /** The tab last shown, kept for the session so reopening the panel lands where it was. */ private var selected = Tab.BOTH + /** The same for the unfolded screen's panel, which opens on the gamepad. */ + private var selectedWithPad = Tab.GAMEPAD private const val CORNER = 20f private const val CORNER_PILL = 22f @@ -348,5 +705,6 @@ class DeckControlsPanel( private val GUIDE = Color.rgb(0x16, 0x18, 0x1B) private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7) private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A) + private val KNOB = Color.rgb(0x2A, 0x2D, 0x33) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GamepadGeometry.kt b/app/src/main/java/com/droiddeck/launcher/ui/GamepadGeometry.kt new file mode 100644 index 000000000..242f90937 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GamepadGeometry.kt @@ -0,0 +1,116 @@ +package com.droiddeck.launcher.ui + +import kotlin.math.min + +/** + * Where the unfolded screen's gamepad puts each control, for a panel of a given size: plain + * numbers, so the layout is tested without a device (GamepadGeometryTest). + * + * Each thumb pivots at its bottom corner; its main control (a stick or trackpad) sits where it + * rests, diagonally in from the corner, and the right side mirrors the left. + * + * A wide strip (a foldable held flat as a tablet): the second control - the d-pad, the face + * buttons - a slide inward along the arc, the shoulders above, and View, Steam, QAM and Menu + * between the arcs. The reach R is the strip's height, or a third of its width. + * + * A tall panel (held upright): the second control a slide up the arc instead, the shoulders above + * that, View, Steam, QAM and Menu between the two sticks, and Panels - the Deck controls' other + * tabs - between the shoulders. Each side's column S is half the width, or as much as the height + * holds. A wide strip has no room for Panels and leaves it out. + */ +object GamepadGeometry { + enum class Slot { + LEFT_MAIN, LEFT_SECOND, RIGHT_MAIN, RIGHT_SECOND, + LEFT_OUTER_SHOULDER, LEFT_INNER_SHOULDER, RIGHT_INNER_SHOULDER, RIGHT_OUTER_SHOULDER, + VIEW, STEAM, QAM, MENU, + /** The Deck controls' other tabs: on a tall panel only. */ + PANELS, + } + + /** A control's box: its centre and size, in pixels. */ + data class Box(val cx: Float, val cy: Float, val width: Float, val height: Float) { + val left get() = cx - width / 2f + val top get() = cy - height / 2f + val right get() = cx + width / 2f + val bottom get() = cy + height / 2f + + fun overlaps(other: Box): Boolean = + left < other.right && other.left < right && top < other.bottom && other.top < bottom + } + + /** The middle buttons, in a row in this order; two by two as on a Deck, View and Menu above Steam and QAM. */ + private val middle = listOf(Slot.VIEW, Slot.STEAM, Slot.QAM, Slot.MENU) + private val twoByTwo = mapOf(Slot.VIEW to 0, Slot.MENU to 1, Slot.STEAM to 2, Slot.QAM to 3) + + /** A panel this shape is laid out as a tall panel (held upright), which has room for Panels; + * anything flatter is a wide strip. */ + fun tall(w: Float, h: Float): Boolean = h >= w * 0.45f + + fun layout(w: Float, h: Float, density: Float): Map { + if (w <= 0f || h <= 0f) return emptyMap() + val pad = 8 * density + val buttonH = 40 * density + // A row needs its buttons comfortable; two by two will take them a little narrower. + val rowButton = 64 * density + val gridButton = 48 * density + val gutter = 8 * density + val boxes = LinkedHashMap() + fun mirror(x: Float, right: Boolean) = if (right) w - x else x + fun pair(left: Slot, right: Slot, x: Float, cy: Float, bw: Float, bh: Float) { + boxes[left] = Box(mirror(x, false), cy, bw, bh) + boxes[right] = Box(mirror(x, true), cy, bw, bh) + } + fun middleGrid(columns: Int, span: Float, top: Float) { + val each = (span / columns).coerceAtMost(110 * density) + for ((order, slot) in middle.withIndex()) { + val index = if (columns == 2) twoByTwo.getValue(slot) else order + val x = w / 2f + (index % columns - (columns - 1) / 2f) * each + boxes[slot] = Box(x, top + buttonH / 2f + (index / columns) * (buttonH + 10 * density), each - gutter, buttonH) + } + } + if (!tall(w, h)) { + val reach = min(h - pad, w * 0.33f) + val main = reach * 0.66f + val second = reach * 0.54f + val mainTop = h - reach * 0.40f - main / 2f + val shoulderW = reach * 0.36f + val shoulderH = (reach * 0.16f).coerceAtLeast(buttonH) + // Above the main controls, never over them, and inside the panel. + val shoulderY = min(h - reach * 0.94f + shoulderH / 2f, mainTop - gutter - shoulderH / 2f) + .coerceAtLeast(pad + shoulderH / 2f) + pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, reach * 0.40f, h - reach * 0.40f, main, main) + pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, reach * 1.03f, h - reach * 0.30f, second, second) + pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, reach * 0.22f, shoulderY, shoulderW, shoulderH) + pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, reach * 0.62f, shoulderY, shoulderW, shoulderH) + // Between the arcs: in a row if they fit, else two by two, else one above another. + val gap = w - 2 * reach * 1.32f + val columns = when { + gap >= middle.size * rowButton -> middle.size + gap >= 2 * gridButton -> 2 + else -> 1 + } + middleGrid(columns, gap - 2 * gutter, shoulderY - buttonH / 2f) + } else { + val side = min(w * 0.5f, h * 0.70f) + val main = side * 0.62f + val second = side * 0.46f + val secondCy = h - side * 0.97f + val shoulderW = side * 0.34f + val shoulderH = (side * 0.13f).coerceAtLeast(buttonH) + val shoulderY = (secondCy - second / 2f - gutter - shoulderH / 2f).coerceAtLeast(pad + shoulderH / 2f) + pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, side * 0.40f, h - side * 0.40f, main, main) + pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, side * 0.74f, secondCy, second, second) + pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, side * 0.20f, shoulderY, shoulderW, shoulderH) + pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, side * 0.57f, shoulderY, shoulderW, shoulderH) + // Between the two sticks at the bottom: two by two, or one above another. + val gap = w - 2 * (side * 0.40f + main / 2f) + val columns = if (gap >= 2 * gridButton) 2 else 1 + val rows = (middle.size + columns - 1) / columns + middleGrid(columns, gap - 2 * gutter, h - pad - rows * buttonH - (rows - 1) * 10 * density) + // Between the inner shoulders, where it fits. + val panelsW = min(112 * density, w - 2 * (side * 0.57f + shoulderW / 2f) - 2 * gutter) + if (panelsW >= buttonH) boxes[Slot.PANELS] = Box(w / 2f, shoulderY, panelsW, buttonH) + } + return boxes + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 347a8c96b..0823c8b18 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -95,6 +95,8 @@ class ModeSettings( val mangoapp: Boolean = true, /** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */ val steamController: String? = null, + /** Unfolded controls (Steam only; null hides the row). */ + val unfoldedControls: Boolean? = null, /** Steam only: start a Steam session when DroidDeck opens. */ val runSteamAtStartup: Boolean = false, /** Null outside Steam; the saved choice is separate from Android's access and device switch. */ @@ -144,6 +146,7 @@ class ModeSettingsActions( val onSteamDeckMode: (Boolean) -> Unit = {}, val onMangoapp: (Boolean) -> Unit = {}, val onSteamController: (String) -> Unit = {}, + val onUnfoldedControls: (Boolean) -> Unit = {}, val onRunSteamAtStartup: (Boolean) -> Unit = {}, val onWifiDiscovery: (Boolean) -> Unit = {}, val onWifiDiscoverySettings: () -> Unit = {}, @@ -271,6 +274,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { 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( 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 cb29e42fc..03c21fd34 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -242,6 +242,9 @@ class DrawerActions( val pipAutoEnter: Boolean = false, val onPip: () -> Unit = {}, val onPipAutoEnter: (Boolean) -> Unit = {}, + /** Unfolded controls (on a foldable; null hides the row). */ + val unfoldedControls: Boolean? = null, + val onUnfoldedControls: (Boolean) -> Unit = {}, val onShareLogs: () -> Unit, val onStop: () -> Unit, /** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */ @@ -445,6 +448,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null, listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)), a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted) + if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls), + stringResource(R.string.unfolded_controls_hint), a.unfoldedControls, + chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls) } SettingsGroup(stringResource(R.string.drawer_keyboard)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d4121c66..14abe19e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -973,6 +973,12 @@ Left pad Right pad Both + Gamepad + Stick + Trackpad + Switch between stick and trackpad + More Deck controls + PANELS Close second-screen controls Open Steam menu Left ยท upper @@ -1036,6 +1042,8 @@ Proton games Picture in picture Automatically enter PiP + Controls on the unfolded screen + Unfolded, the game sits above the Steam Deck trackpads and back grips. Folded, the game is full screen. Suspended Picture in picture is available once the session is ready. Allow picture in picture for DroidDeck in Android settings. diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt index 6093ea80c..1c26f7814 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt @@ -38,6 +38,15 @@ class SessionDisplayTest { assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN)) } + @Test fun unfoldedControlsKeepTheGameSixteenByNineAcrossTheWidth() { + // Fold inner screen held as a tablet, and upright: 16:9 either way, the controls the rest. + assertEquals(1376, SessionDisplay.unfoldedGameHeight(2448, 1848)) + assertEquals(1038, SessionDisplay.unfoldedGameHeight(1848, 2448)) + // A window near 16:9 itself still leaves the controls a fifth of it. + assertEquals(864, SessionDisplay.unfoldedGameHeight(1920, 1080)) + } + + @Test fun followScreenKeepsTheWindowShapeAndSkipsTinyWindows() { assertEquals(2520 to 1080, SessionDisplay.resolveChoice(1080 to 2520, SessionDisplay.FOLLOW_SCREEN)) assertEquals(1968 to 2184, SessionDisplay.followSize(1968, 2184)) diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GamepadGeometryTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GamepadGeometryTest.kt new file mode 100644 index 000000000..77a8d5b8e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GamepadGeometryTest.kt @@ -0,0 +1,88 @@ +package com.droiddeck.launcher.ui + +import com.droiddeck.launcher.session.SessionDisplay +import com.droiddeck.launcher.ui.GamepadGeometry.Slot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GamepadGeometryTest { + /** A foldable's unfolded panel (px) and density, as the gamepad gets it under a 16:9 game. */ + private data class Panel(val name: String, val width: Int, val height: Int, val density: Float) { + val controlsHeight get() = height - SessionDisplay.unfoldedGameHeight(width, height) + } + + private val panels = listOf( + Panel("Fold 8 upright", 1848, 2448, 2.625f), + Panel("Fold 8 as a tablet", 2448, 1848, 2.625f), + Panel("Pixel Fold upright", 1840, 2208, 2.625f), + Panel("Pixel Fold as a tablet", 2208, 1840, 2.625f), + Panel("OnePlus Open upright", 2268, 2440, 2.75f), + Panel("OnePlus Open as a tablet", 2440, 2268, 2.75f), + Panel("Fold 8 upright, large display size", 1848, 2448, 3.5f), + Panel("Fold 8 as a tablet, small display size", 2448, 1848, 2.0f), + ) + + private fun boxes(panel: Panel) = + GamepadGeometry.layout(panel.width.toFloat(), panel.controlsHeight.toFloat(), panel.density) + + @Test fun everyControlIsPlacedAndPanelsOnlyWhenUpright() { + for (panel in panels) { + val upright = panel.height > panel.width + val expected = Slot.values().toSet() - if (upright) emptySet() else setOf(Slot.PANELS) + assertEquals(panel.name, expected, boxes(panel).keys) + } + } + + @Test fun everyControlStaysOnThePanel() { + for (panel in panels) for ((slot, box) in boxes(panel)) { + val where = "${panel.name}: $slot $box" + assertTrue(where, box.left >= 0f && box.top >= 0f) + assertTrue(where, box.right <= panel.width && box.bottom <= panel.controlsHeight) + } + } + + @Test fun noControlsOverlap() { + for (panel in panels) { + val placed = boxes(panel).entries.toList() + for (i in placed.indices) for (j in i + 1 until placed.size) { + assertFalse("${panel.name}: ${placed[i].key} overlaps ${placed[j].key}", + placed[i].value.overlaps(placed[j].value)) + } + } + } + + @Test fun everyControlIsBigEnoughForAThumb() { + for (panel in panels) for ((slot, box) in boxes(panel)) { + val minPx = 40 * panel.density + assertTrue("${panel.name}: $slot is ${box.width}x${box.height}, under 40dp", + box.width >= minPx - 1 && box.height >= minPx - 1) + } + } + + @Test fun mainControlsSitUnderTheThumbsAndMirror() { + for (panel in panels) { + val b = boxes(panel) + val left = b.getValue(Slot.LEFT_MAIN) + val right = b.getValue(Slot.RIGHT_MAIN) + assertEquals(panel.name, left.cy, right.cy, 0.5f) + assertEquals(panel.name, left.cx, panel.width - right.cx, 0.5f) + // In the bottom half of their side, nearer the corner than the second controls. + assertTrue(panel.name, left.cx < b.getValue(Slot.LEFT_SECOND).cx) + assertTrue(panel.name, left.cy > panel.controlsHeight / 2f) + } + } + + @Test fun middleButtonsKeepTheDeckOrder() { + for (panel in panels) { + val b = boxes(panel) + // View before Menu and Steam before QAM: left of it in a row or two by two, above it + // in a single column. + fun before(a: Slot, c: Slot) = b.getValue(a).let { x -> b.getValue(c).let { y -> + x.cx < y.cx - 0.5f || (kotlin.math.abs(x.cx - y.cx) <= 0.5f && x.cy < y.cy) } } + assertTrue(panel.name, before(Slot.VIEW, Slot.MENU)) + assertTrue(panel.name, before(Slot.STEAM, Slot.QAM)) + } + } +}