mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
Unfolded deck controls (#407)
This commit is contained in:
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)) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user