Unfolded deck controls (#407)

This commit is contained in:
SG3xHERO authored and GitHub committed 2026-10-10 17:24:23 +00:00
1 parent d41424170a
commit ca6cfcd193
11 files changed
+724 -20

No files matched your search

@@ -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
@@ -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<Int>()
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?) {
@@ -52,6 +52,26 @@ object SessionDisplay {
fun followSize(width: Int, height: Int): Pair<Int, Int>? =
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<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
@@ -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 }
@@ -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<Tab, TextView>()
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<Int>()
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<List<Pair<Float, Float>>, 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<Int>()
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<Int>) {
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<View, GamepadGeometry.Slot>()
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)
}
}
@@ -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<Slot, Box> {
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<Slot, Box>()
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
}
}
@@ -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(
@@ -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)) {
+8
View File
@@ -973,6 +973,12 @@
<string name="deck_tab_left">Left pad</string>
<string name="deck_tab_right">Right pad</string>
<string name="deck_tab_both">Both</string>
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Switch between stick and trackpad</string>
<string name="deck_more_controls">More Deck controls</string>
<string name="deck_panels">PANELS</string>
<string name="deck_close">Close second-screen controls</string>
<string name="deck_open_steam_menu">Open Steam menu</string>
<string name="deck_left_upper">Left · upper</string>
@@ -1036,6 +1042,8 @@
<string name="game_env_eyebrow">Proton games</string>
<string name="pip_title">Picture in picture</string>
<string name="pip_auto">Automatically enter PiP</string>
<string name="unfolded_controls">Controls on the unfolded screen</string>
<string name="unfolded_controls_hint">Unfolded, the game sits above the Steam Deck trackpads and back grips. Folded, the game is full screen.</string>
<string name="pip_suspended">Suspended</string>
<string name="pip_not_ready">Picture in picture is available once the session is ready.</string>
<string name="pip_permission">Allow picture in picture for DroidDeck in Android settings.</string>
@@ -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))
@@ -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))
}
}
}