mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
Unify input profiles and configurable controller surfaces
This commit is contained in:
1 parent
906e0d1f7d
commit
ab8f41b80e
33 files changed
+2526
-198
No files matched your search
@@ -59,7 +59,6 @@ import com.droiddeck.launcher.ui.ModeSettings
|
||||
import com.droiddeck.launcher.ui.ModeSettingsActions
|
||||
import com.droiddeck.launcher.ui.ConfirmDialog
|
||||
import com.droiddeck.launcher.ui.ControllerActions
|
||||
import com.droiddeck.launcher.ui.ControllerMappingPage
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.ControllerEditorActivity
|
||||
import com.droiddeck.launcher.ui.FrontEndScreen
|
||||
@@ -1095,12 +1094,11 @@ class FrontEndActivity : ComponentActivity() {
|
||||
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
|
||||
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
|
||||
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
|
||||
onResetAll = { ControllerPrefs.resetAll(this); com.droiddeck.launcher.input.InputProfiles.resetAll(this); refreshController() },
|
||||
),
|
||||
),
|
||||
page = page,
|
||||
@@ -1432,13 +1430,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
private fun MappingHost() {
|
||||
val settings = controllerSettings ?: return
|
||||
ControllerMappingPage(
|
||||
mapping = settings.mapping,
|
||||
onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() },
|
||||
onReset = { ControllerPrefs.resetMapping(this); refreshController() },
|
||||
onBack = { showMapping = false },
|
||||
)
|
||||
com.droiddeck.launcher.ui.InputSettingsPage(onBack = { showMapping = false })
|
||||
}
|
||||
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
|
||||
|
||||
@@ -72,6 +72,17 @@ import com.droiddeck.launcher.input.KeyboardHost
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.SessionClipboard
|
||||
import com.droiddeck.launcher.input.OnScreenControls
|
||||
import com.droiddeck.launcher.input.InputRouter
|
||||
import com.droiddeck.launcher.input.InputOutput
|
||||
import com.droiddeck.launcher.input.InputCommand
|
||||
import com.droiddeck.launcher.input.InputProfiles
|
||||
import com.droiddeck.launcher.input.InputProfile
|
||||
import com.droiddeck.launcher.input.InputAction
|
||||
import com.droiddeck.launcher.input.InputRole
|
||||
import com.droiddeck.launcher.input.InputControl
|
||||
import com.droiddeck.launcher.input.InputMotion
|
||||
import com.droiddeck.launcher.input.HeldInput
|
||||
import com.droiddeck.launcher.input.DeckControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadMouse
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
@@ -150,6 +161,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null
|
||||
private lateinit var sessionRoot: FrameLayout
|
||||
private var inputRouter: InputRouter? = null
|
||||
private var physicalInputBindings: com.droiddeck.launcher.input.PhysicalInputBindings? = null
|
||||
private var inputSettingsOpen by mutableStateOf(false)
|
||||
@Volatile private var inputUsesLocalActions = false
|
||||
private var inputProfile = InputProfile.defaults(InputProfile.GAME)
|
||||
private val inputMotion = InputMotion { inputRouter }
|
||||
private val heldKeys = HeldInput { code, down -> WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) }
|
||||
private val heldButtons = HeldInput { code, down -> WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0) }
|
||||
private val inputOutput = object : InputOutput {
|
||||
override fun key(code: Int, down: Boolean) { heldKeys.set(4, code, down) }
|
||||
override fun button(code: Int, down: Boolean) { heldButtons.set(4, code, down) }
|
||||
override fun pointer(dx: Float, dy: Float) { WaylandCompositor.sendPointerDelta(dx, dy) }
|
||||
override fun scroll(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
|
||||
override fun requestMotion() { inputMotion.request() }
|
||||
override fun command(command: InputCommand) {
|
||||
uiHandler.post {
|
||||
if (!resumed || isDestroyed || pipUi || SessionState.suspended) return@post
|
||||
when (command) {
|
||||
InputCommand.MENU -> { releasePads(); drawerOpen = true }
|
||||
InputCommand.KEYBOARD -> if (secondScreenPresentation?.showKeyboard() != true) togglePcKeyboard()
|
||||
InputCommand.STEAM -> tapSteamButton()
|
||||
InputCommand.QUICK_ACCESS -> tapQam()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private val guestKeysDown = mutableSetOf<Int>()
|
||||
private lateinit var surfaceView: SurfaceView
|
||||
private lateinit var sessionOverlay: ComposeView
|
||||
@@ -159,7 +196,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var joyConVr: com.droiddeck.launcher.input.JoyConVr? = null
|
||||
private var handCamera: com.droiddeck.launcher.handtracking.HandCamera? = null
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
private var resumed = false
|
||||
private var resumed by mutableStateOf(false)
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } }
|
||||
|
||||
private fun openDeckRing(why: String) {
|
||||
@@ -431,6 +468,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
joyConVr?.refresh()
|
||||
physicalInputBindings?.disconnected(deviceId)
|
||||
padBridge?.onDeviceRemoved(deviceId)
|
||||
if (::padMouse.isInitialized) padMouse.reset()
|
||||
refreshControls()
|
||||
@@ -509,6 +547,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
|
||||
bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this))
|
||||
padBridge = bridge
|
||||
inputRouter = InputRouter(inputOutput).also { bridge.setInputRouter(it); DeckControls.setRouter(it); physicalInputBindings = com.droiddeck.launcher.input.PhysicalInputBindings(it) }
|
||||
refreshInputProfile()
|
||||
SessionState.padBridge = bridge
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val vr = com.droiddeck.launcher.input.JoyConVr(this, File(filesDir, ".wayland-rt/${com.droiddeck.launcher.input.JoyConVr.FILE}")) { taken, released ->
|
||||
@@ -521,7 +561,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
joyConVr = vr
|
||||
}
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); refreshControls() } }
|
||||
steamController = SteamControllerBle(this).also { it.start() }
|
||||
steamController = SteamControllerBle(this, inputRouter).also { it.start() }
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
@@ -639,6 +679,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (pipUi) return
|
||||
when {
|
||||
::loading.isInitialized && loading.ended -> finish()
|
||||
inputSettingsOpen -> inputSettingsOpen = false
|
||||
drawerOpen -> drawerOpen = false
|
||||
achievementsOpen -> closeAchievements()
|
||||
pcKeyboardOpen -> pcKeyboardOpen = false
|
||||
@@ -730,12 +771,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() }
|
||||
}
|
||||
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
|
||||
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
|
||||
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, inputSettingsOpen, pipUi, SessionState.suspended, resumed) {
|
||||
padBridge?.setInputEnabled(resumed && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen && !inputSettingsOpen && !pipUi && !SessionState.suspended)
|
||||
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended) {
|
||||
cancelTouchGestures()
|
||||
}
|
||||
}
|
||||
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
|
||||
if (inputSettingsOpen) com.droiddeck.launcher.ui.InputSettingsDialog(
|
||||
onBack = { inputSettingsOpen = false; secondScreenPresentation?.refreshControls() },
|
||||
onChanged = ::refreshInputProfile,
|
||||
)
|
||||
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
|
||||
game = gestureGame,
|
||||
onDismiss = { gestureSettingsOpen = false },
|
||||
@@ -781,7 +827,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset()
|
||||
}
|
||||
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
|
||||
sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) },
|
||||
sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down, 32) },
|
||||
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onClose = { pcKeyboardOpen = false },
|
||||
)
|
||||
@@ -927,6 +973,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onInputSettings = { releasePads(); drawerOpen = false; inputSettingsOpen = true },
|
||||
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
@@ -1478,7 +1525,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (CompositorHost.isStarted) {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
heldButtons.set(1, it, false)
|
||||
}
|
||||
}
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
|
||||
@@ -1963,6 +2010,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun releasePads() {
|
||||
inputMotion.stop()
|
||||
physicalInputBindings?.release()
|
||||
padBridge?.releaseAll()
|
||||
joyConVr?.releaseButtons()
|
||||
}
|
||||
@@ -2038,7 +2087,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (pipUi) return true
|
||||
val pad = com.droiddeck.launcher.input.JoyCon.normalize(event)
|
||||
if (pad !== event) return dispatchKeyEvent(pad)
|
||||
if (physicalInputBindings?.onKey(event, resumed && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
|
||||
!inputSettingsOpen && !gestureSettingsOpen && !SessionState.suspended && !loading.visible) == true) return true
|
||||
val fromController = PadBridge.isControllerKeyEvent(event)
|
||||
if (fromController && (drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || gestureSettingsOpen || SessionState.suspended)) {
|
||||
padBridge?.setInputEnabled(false)
|
||||
padBridge?.onKeyEvent(event)
|
||||
}
|
||||
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
|
||||
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
@@ -2088,7 +2143,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event)
|
||||
if ((pcKeyboardOpen || achievementsOpen || inputSettingsOpen) && fromController) return super.dispatchKeyEvent(event)
|
||||
if (joyConVr?.onKeyEvent(event) == true) return true
|
||||
if (fromController && event.keyCode != KeyEvent.KEYCODE_BACK && padMouseMode() && padMouse.onKey(event)) return true
|
||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
|
||||
@@ -2106,12 +2161,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val shiftEvdev = if (evdev > 0 && plain != 0 && event.deviceId <= 0) 42 else 0
|
||||
if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode
|
||||
if (evdev > 0) {
|
||||
val owner = if (event.deviceId <= 0) 16 else 8
|
||||
if (down) {
|
||||
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true)
|
||||
sendGuestKey(evdev, true)
|
||||
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true, owner)
|
||||
sendGuestKey(evdev, true, owner)
|
||||
} else {
|
||||
sendGuestKey(evdev, false)
|
||||
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false)
|
||||
sendGuestKey(evdev, false, owner)
|
||||
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false, owner)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -2122,6 +2178,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (PadBridge.isControllerMotionEvent(event) && (drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || gestureSettingsOpen || SessionState.suspended)) {
|
||||
padBridge?.setInputEnabled(false)
|
||||
padBridge?.onMotionEvent(event)
|
||||
}
|
||||
if (drawerOpen && PadBridge.isControllerMotionEvent(event)) {
|
||||
if (!drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
@@ -2133,7 +2193,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
|
||||
if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if ((pcKeyboardOpen || achievementsOpen || inputSettingsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if (joyConVr?.onMotionEvent(event) == true) return true
|
||||
if (PadBridge.isControllerMotionEvent(event) && padMouseMode() && padMouse.onMotion(event)) return true
|
||||
if (padBridge?.onMotionEvent(event) == true) return true
|
||||
@@ -2249,7 +2309,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
} else moveTo(gesturePointerX + dx, gesturePointerY + dy)
|
||||
}
|
||||
override fun button(code: Int, down: Boolean) {
|
||||
WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0)
|
||||
heldButtons.set(16, code, down)
|
||||
}
|
||||
override fun prepareRelativePan() {
|
||||
if (pointerLocked) return
|
||||
@@ -2261,7 +2321,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
override fun key(code: Int, down: Boolean) {
|
||||
if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code)
|
||||
if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
|
||||
heldKeys.set(2, code, down)
|
||||
}
|
||||
override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
|
||||
override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } }
|
||||
@@ -2278,7 +2338,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
|
||||
if (!pointerLocked) movePointer(x, y)
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
|
||||
heldButtons.set(1, button, pressed)
|
||||
}
|
||||
override fun onWheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
|
||||
override fun onLongPress() {
|
||||
@@ -2296,7 +2356,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
cursorVisible = false
|
||||
}
|
||||
override fun onButton(button: Int, pressed: Boolean) {
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
|
||||
heldButtons.set(1, button, pressed)
|
||||
}
|
||||
override fun onScroll(dx: Float, dy: Float) {
|
||||
val rect = drawnRect() ?: return
|
||||
@@ -2336,6 +2396,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
private fun padMouseMode(): Boolean {
|
||||
val now = android.os.SystemClock.uptimeMillis()
|
||||
if (inputUsesLocalActions) {
|
||||
if (padMouseOn) padMouse.reset()
|
||||
padMouseOn = false
|
||||
return false
|
||||
}
|
||||
if (now - padMouseCheckedAt < PAD_MOUSE_CHECK_MS) return padMouseOn
|
||||
padMouseCheckedAt = now
|
||||
val on = !pointerLocked && EpicClient.launcherFocused(this) && !EpicClient.playing(this)
|
||||
@@ -2463,7 +2528,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
this, target,
|
||||
sendPointer = ::moveSecondScreenPointer,
|
||||
sendButton = { button, pressed ->
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
|
||||
heldButtons.set(2, button, pressed)
|
||||
},
|
||||
sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) },
|
||||
onSteamMenu = ::tapSteamButton,
|
||||
@@ -2486,12 +2551,15 @@ 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)
|
||||
}
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerSteamMenu()
|
||||
}
|
||||
|
||||
private fun refreshInputProfile() {
|
||||
inputProfile = InputProfiles.active(this, loadingMode() == SessionService.MODE_DESKTOP)
|
||||
inputUsesLocalActions = InputControl.padControls.any { inputProfile.action(InputRole.PHYSICAL, it) !is InputAction.Native }
|
||||
physicalInputBindings?.configure(inputProfile.keys)
|
||||
padBridge?.setInputProfile(inputProfile)
|
||||
DeckControls.refresh()
|
||||
}
|
||||
|
||||
private fun tapQam() {
|
||||
@@ -2545,7 +2613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
if (panel == null) {
|
||||
panel = com.droiddeck.launcher.ui.DeckControlsPanel(this, onClose = { chooseUnfoldedControls(false) },
|
||||
onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge)
|
||||
onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge, inputRole = InputRole.PRIMARY)
|
||||
sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1)
|
||||
unfoldedPanel = panel
|
||||
updateOnScreenControls()
|
||||
@@ -2629,7 +2697,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
||||
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true
|
||||
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended) return true
|
||||
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false
|
||||
return touchscreenGestures.onTouch(event)
|
||||
@@ -2678,7 +2746,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
heldButtons.set(1, it, false)
|
||||
}
|
||||
sendTouch(0, event.actionIndex)
|
||||
}
|
||||
@@ -2702,7 +2770,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
|
||||
else -> PointerGestures.BTN_LEFT
|
||||
}
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
|
||||
heldButtons.set(8, button, event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS)
|
||||
}
|
||||
MotionEvent.ACTION_SCROLL -> {
|
||||
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
@@ -2745,7 +2813,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
|
||||
else -> PointerGestures.BTN_LEFT
|
||||
}
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
|
||||
heldButtons.set(8, button, event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS)
|
||||
}
|
||||
MotionEvent.ACTION_SCROLL -> {
|
||||
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
@@ -2867,10 +2935,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun keyboardCoversPad() =
|
||||
SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard
|
||||
|
||||
private fun sendGuestKey(code: Int, down: Boolean) {
|
||||
private fun sendGuestKey(code: Int, down: Boolean, owner: Int = 1) {
|
||||
if (down && pipUi) return
|
||||
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
|
||||
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
|
||||
if (owner == 1) { if (down) guestKeysDown.add(code) else guestKeysDown.remove(code) }
|
||||
heldKeys.set(owner, code, down)
|
||||
}
|
||||
|
||||
// The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame.
|
||||
@@ -2944,6 +3012,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
scrollTouch.cancel()
|
||||
directTouchHold.cancel()
|
||||
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
|
||||
heldKeys.clear()
|
||||
heldButtons.clear()
|
||||
keyboard?.hide()
|
||||
sessionClipboard.stop()
|
||||
padMotion?.stop()
|
||||
@@ -2954,7 +3024,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (CompositorHost.isStarted) {
|
||||
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
heldButtons.set(1, it, false)
|
||||
}
|
||||
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
|
||||
}
|
||||
@@ -3065,6 +3135,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (!pipUi) sessionClipboard.start()
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
refreshInputProfile()
|
||||
refreshControls()
|
||||
if (::pip.isInitialized) pip.resumed()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
@@ -3074,12 +3145,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onPause() {
|
||||
if (runtimeRemovalBlocked) { super.onPause(); return }
|
||||
capturePausedFrame()
|
||||
padBridge?.setInputEnabled(false)
|
||||
sessionClipboard.stop()
|
||||
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (::directTouchHold.isInitialized) directTouchHold.cancel()
|
||||
uiHandler.removeCallbacks(clearRecognizedGesture)
|
||||
gestureSettingsOpen = false
|
||||
inputSettingsOpen = false
|
||||
releasePads()
|
||||
heldKeys.clear()
|
||||
heldButtons.clear()
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
achievementsOpen = false
|
||||
@@ -3518,7 +3594,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
joyConVr = null
|
||||
handCamera?.stop()
|
||||
handCamera = null
|
||||
inputMotion.stop()
|
||||
padBridge?.setInputEnabled(false)
|
||||
physicalInputBindings?.close()
|
||||
padBridge?.stop()
|
||||
DeckControls.setRouter(null)
|
||||
inputRouter = null
|
||||
heldKeys.clear()
|
||||
heldButtons.clear()
|
||||
steamController?.stop()
|
||||
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
|
||||
padMotion?.stop()
|
||||
|
||||
@@ -5,53 +5,139 @@ object DeckControls {
|
||||
const val R4 = 2
|
||||
const val L5 = 4
|
||||
const val R5 = 8
|
||||
private const val LEFT_TOUCH = 16
|
||||
private const val RIGHT_TOUCH = 32
|
||||
private const val LEFT_CLICK = 64
|
||||
private const val RIGHT_CLICK = 128
|
||||
|
||||
private const val SLOT = 0
|
||||
|
||||
private var controls = 0
|
||||
private val controls = arrayOf(InputControl.L4, InputControl.R4, InputControl.L5, InputControl.R5,
|
||||
InputControl.LEFT_PAD, InputControl.RIGHT_PAD, InputControl.LEFT_CLICK, InputControl.RIGHT_CLICK)
|
||||
private val sources = ArrayList<Source>()
|
||||
private val physical = Source(InputRole.PHYSICAL)
|
||||
private var router: InputRouter? = null
|
||||
private val pads = ShortArray(4)
|
||||
private val pressure = ShortArray(2)
|
||||
|
||||
@Synchronized
|
||||
fun setGrip(bit: Int, down: Boolean) {
|
||||
set(bit, down)
|
||||
fun setRouter(next: InputRouter?) {
|
||||
for (source in sources) source.closeRoutes()
|
||||
router = next
|
||||
for (source in sources) source.configure()
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
|
||||
val at = if (right) 2 else 0
|
||||
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
|
||||
pads[at] = if (touching) axis(x) else 0
|
||||
pads[at + 1] = if (touching) axis(y) else 0
|
||||
publish()
|
||||
}
|
||||
fun source(role: InputRole) = Source(role)
|
||||
|
||||
@Synchronized
|
||||
fun setClick(right: Boolean, down: Boolean) {
|
||||
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
|
||||
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
|
||||
publish()
|
||||
}
|
||||
fun setGrip(bit: Int, down: Boolean) = physical.setGrip(bit, down)
|
||||
|
||||
@Synchronized
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = physical.setPad(right, touching, x, y)
|
||||
|
||||
@Synchronized
|
||||
fun setClick(right: Boolean, down: Boolean) = physical.setClick(right, down)
|
||||
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
|
||||
controls = 0
|
||||
pads.fill(0)
|
||||
pressure.fill(0)
|
||||
for (source in sources) source.clear()
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun set(bit: Int, on: Boolean) {
|
||||
controls = if (on) controls or bit else controls and bit.inv()
|
||||
@Synchronized
|
||||
fun suspend() {
|
||||
for (source in sources) source.suspend()
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
@Synchronized
|
||||
fun refresh() {
|
||||
for (source in sources) source.route()
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
|
||||
class Source internal constructor(private val role: InputRole) {
|
||||
private val x = FloatArray(8)
|
||||
private val y = FloatArray(8)
|
||||
private val touched = BooleanArray(8)
|
||||
private var routes: Array<InputRouter.Channel>? = null
|
||||
internal var mask = 0
|
||||
internal val axes = ShortArray(4)
|
||||
|
||||
internal fun configure() {
|
||||
routes = router?.let { r -> Array(8) { r.channel(role, controls[it]) } }
|
||||
}
|
||||
|
||||
private fun attach() {
|
||||
if (this !in sources) { sources.add(this); configure() }
|
||||
}
|
||||
|
||||
fun setGrip(bit: Int, down: Boolean) = synchronized(DeckControls) {
|
||||
val at = when (bit) { L4 -> 0; R4 -> 1; L5 -> 2; R5 -> 3; else -> return@synchronized }
|
||||
update(at, if (down) 1f else 0f)
|
||||
}
|
||||
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) = synchronized(DeckControls) {
|
||||
update(if (right) 5 else 4, if (touching) x else 0f, if (touching) y else 0f, touching)
|
||||
}
|
||||
|
||||
fun setClick(right: Boolean, down: Boolean) = synchronized(DeckControls) {
|
||||
update(if (right) 7 else 6, if (down) 1f else 0f)
|
||||
}
|
||||
|
||||
private fun update(at: Int, value: Float, second: Float = 0f, contact: Boolean = false) {
|
||||
val timing = InputTiming.begin()
|
||||
attach()
|
||||
x[at] = value; y[at] = second; touched[at] = contact
|
||||
route()
|
||||
publish()
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
|
||||
internal fun route() {
|
||||
mask = 0
|
||||
axes.fill(0)
|
||||
for (i in controls.indices) {
|
||||
val target = if (routes == null) controls[i] else routes!![i].update(x[i], y[i], touched[i]) ?: continue
|
||||
val at = controls.indexOf(target)
|
||||
if (at < 0) continue
|
||||
if (at == 4 || at == 5) {
|
||||
if (touched[i]) {
|
||||
mask = mask or (1 shl at)
|
||||
axes[(at - 4) * 2] = axis(x[i]); axes[(at - 4) * 2 + 1] = axis(y[i])
|
||||
}
|
||||
} else if (x[i] > 0.5f) mask = mask or (1 shl at)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun clear() {
|
||||
x.fill(0f); y.fill(0f); touched.fill(false); mask = 0; axes.fill(0)
|
||||
routes?.forEach { it.release() }
|
||||
}
|
||||
|
||||
internal fun suspend() {
|
||||
mask = 0; axes.fill(0)
|
||||
routes?.forEach { it.suspend() }
|
||||
}
|
||||
|
||||
fun release() = synchronized(DeckControls) { clear(); publish() }
|
||||
|
||||
internal fun closeRoutes() { routes?.forEach { it.close() }; routes = null }
|
||||
|
||||
fun close() = synchronized(DeckControls) {
|
||||
clear(); closeRoutes(); sources.remove(this); publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun axis(value: Float) = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
|
||||
private fun publish() {
|
||||
var mask = 0
|
||||
pads.fill(0)
|
||||
for (source in sources) {
|
||||
mask = mask or source.mask
|
||||
for (side in 0..1) if (source.mask and (16 shl side) != 0) {
|
||||
pads[side * 2] = source.axes[side * 2]
|
||||
pads[side * 2 + 1] = source.axes[side * 2 + 1]
|
||||
}
|
||||
}
|
||||
pressure[0] = if (mask and 64 != 0) Short.MAX_VALUE else 0
|
||||
pressure[1] = if (mask and 128 != 0) Short.MAX_VALUE else 0
|
||||
FakeInputWriter.writeDeckControls(0, mask, pads, pressure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
class HeldInput(private val emit: (Int, Boolean) -> Unit) {
|
||||
private val owners = IntArray(768)
|
||||
|
||||
@Synchronized
|
||||
fun set(source: Int, code: Int, down: Boolean) {
|
||||
if (code !in owners.indices) return
|
||||
val before = owners[code]
|
||||
val after = if (down) before or source else before and source.inv()
|
||||
owners[code] = after
|
||||
if ((before == 0) != (after == 0)) emit(code, after != 0)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun release(source: Int) {
|
||||
for (code in owners.indices) if (owners[code] and source != 0) set(source, code, false)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
for (code in owners.indices) if (owners[code] != 0) {
|
||||
owners[code] = 0
|
||||
emit(code, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Choreographer
|
||||
|
||||
class InputMotion(private val router: () -> InputRouter?) : Choreographer.FrameCallback {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var scheduled = false
|
||||
private var lastFrame = 0L
|
||||
private val start = Runnable {
|
||||
if (!scheduled) {
|
||||
scheduled = true
|
||||
lastFrame = System.nanoTime()
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun request() {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) start.run() else handler.post(start)
|
||||
}
|
||||
|
||||
override fun doFrame(frameTimeNanos: Long) {
|
||||
val dt = (frameTimeNanos - lastFrame).coerceAtLeast(0) / 1_000_000_000f
|
||||
lastFrame = frameTimeNanos
|
||||
if (router()?.tick(dt) == true) Choreographer.getInstance().postFrameCallback(this)
|
||||
else scheduled = false
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
handler.removeCallbacks(start)
|
||||
Choreographer.getInstance().removeFrameCallback(this)
|
||||
scheduled = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
enum class InputRole { PHYSICAL, PRIMARY, SECONDARY }
|
||||
|
||||
enum class InputControl(val id: String, val label: String, val dimensions: Int = 1) {
|
||||
A("a", "A"), B("b", "B"), X("x", "X"), Y("y", "Y"),
|
||||
LB("lb", "L1"), RB("rb", "R1"), SELECT("select", "View / Select"), START("start", "Menu / Start"),
|
||||
L3("l3", "L3"), R3("r3", "R3"), GUIDE("guide", "Steam / Guide"), QAM("qam", "Quick access"),
|
||||
UP("up", "D-pad up"), RIGHT("right", "D-pad right"), DOWN("down", "D-pad down"), LEFT("left", "D-pad left"),
|
||||
LT("lt", "L2"), RT("rt", "R2"), LS("ls", "Left stick", 2), RS("rs", "Right stick", 2),
|
||||
L4("l4", "L4"), R4("r4", "R4"), L5("l5", "L5"), R5("r5", "R5"),
|
||||
LEFT_PAD("left-pad", "Left trackpad", 2), RIGHT_PAD("right-pad", "Right trackpad", 2),
|
||||
LEFT_CLICK("left-click", "Left trackpad click"), RIGHT_CLICK("right-click", "Right trackpad click");
|
||||
|
||||
val isPad: Boolean get() = ordinal <= RS.ordinal
|
||||
val isTrackpad: Boolean get() = this == LEFT_PAD || this == RIGHT_PAD
|
||||
|
||||
companion object {
|
||||
val padControls = entries.filter { it.isPad }.toTypedArray()
|
||||
fun fromId(id: String) = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
enum class InputCommand { MENU, KEYBOARD, STEAM, QUICK_ACCESS }
|
||||
|
||||
sealed interface InputAction {
|
||||
data class Native(val target: InputControl? = null) : InputAction
|
||||
data object Disabled : InputAction
|
||||
data class Keys(val codes: List<Int>) : InputAction
|
||||
data class MouseButton(val code: Int) : InputAction
|
||||
data class Command(val command: InputCommand) : InputAction
|
||||
data class Directions(val up: Int, val right: Int, val down: Int, val left: Int) : InputAction
|
||||
data class Pointer(val speed: Float = 900f) : InputAction
|
||||
data class Scroll(val speed: Float = 10f) : InputAction
|
||||
}
|
||||
|
||||
data class InputProfile(
|
||||
val id: String,
|
||||
val bindings: Map<InputRole, Map<InputControl, InputAction>> = emptyMap(),
|
||||
val keys: List<PhysicalInputBinding> = emptyList(),
|
||||
) {
|
||||
fun action(role: InputRole, control: InputControl): InputAction =
|
||||
bindings[role]?.get(control) ?: InputAction.Native()
|
||||
|
||||
fun withBinding(role: InputRole, control: InputControl, action: InputAction) =
|
||||
copy(bindings = bindings + (role to (bindings[role].orEmpty() + (control to action))))
|
||||
|
||||
companion object {
|
||||
const val GAME = "game"
|
||||
const val DESKTOP = "desktop"
|
||||
|
||||
fun defaults(id: String): InputProfile {
|
||||
if (id != DESKTOP) return InputProfile(GAME)
|
||||
val desktop = mapOf(
|
||||
InputControl.LS to InputAction.Pointer(), InputControl.RS to InputAction.Scroll(),
|
||||
InputControl.A to InputAction.MouseButton(272), InputControl.B to InputAction.MouseButton(273),
|
||||
InputControl.X to InputAction.Keys(listOf(28)), InputControl.Y to InputAction.Keys(listOf(1)),
|
||||
InputControl.UP to InputAction.Keys(listOf(103)), InputControl.RIGHT to InputAction.Keys(listOf(106)),
|
||||
InputControl.DOWN to InputAction.Keys(listOf(108)), InputControl.LEFT to InputAction.Keys(listOf(105)),
|
||||
InputControl.LT to InputAction.MouseButton(273), InputControl.RT to InputAction.MouseButton(272),
|
||||
InputControl.LB to InputAction.Keys(listOf(29)), InputControl.RB to InputAction.Keys(listOf(56)),
|
||||
InputControl.SELECT to InputAction.Command(InputCommand.KEYBOARD),
|
||||
InputControl.START to InputAction.Command(InputCommand.MENU),
|
||||
InputControl.LEFT_PAD to InputAction.Scroll(), InputControl.RIGHT_PAD to InputAction.Pointer(),
|
||||
InputControl.LEFT_CLICK to InputAction.MouseButton(273), InputControl.RIGHT_CLICK to InputAction.MouseButton(272),
|
||||
)
|
||||
return InputProfile(DESKTOP, InputRole.entries.associateWith { desktop })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun InputAction.validFor(control: InputControl): Boolean = when (this) {
|
||||
is InputAction.Native -> target == null || (control.isPad == target.isPad && control.dimensions == target.dimensions &&
|
||||
(!control.isTrackpad && !target.isTrackpad || control.isTrackpad && target.isTrackpad))
|
||||
is InputAction.Keys -> codes.isNotEmpty() && codes.size <= 8 && codes.distinct().size == codes.size && codes.all { it in 1..255 }
|
||||
is InputAction.MouseButton -> code in 272..276
|
||||
is InputAction.Directions -> control.dimensions == 2 && listOf(up, right, down, left).all { it in 1..255 }
|
||||
is InputAction.Pointer -> control.dimensions == 2 && speed.isFinite() && speed in 100f..3000f
|
||||
is InputAction.Scroll -> control.dimensions == 2 && speed.isFinite() && speed in 1f..60f
|
||||
else -> true
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object InputProfiles {
|
||||
const val AUTO = "auto"
|
||||
private const val VERSION = 1
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE)
|
||||
|
||||
fun selection(context: Context): String = prefs(context).getString("selected", AUTO)
|
||||
?.takeIf { it in listOf(AUTO, InputProfile.GAME, InputProfile.DESKTOP) } ?: AUTO
|
||||
|
||||
fun select(context: Context, id: String) {
|
||||
require(id in listOf(AUTO, InputProfile.GAME, InputProfile.DESKTOP))
|
||||
prefs(context).edit().putString("selected", id).apply()
|
||||
}
|
||||
|
||||
fun active(context: Context, desktop: Boolean): InputProfile {
|
||||
val selected = selection(context)
|
||||
return read(context, if (selected != AUTO) selected else if (desktop) InputProfile.DESKTOP else InputProfile.GAME)
|
||||
}
|
||||
|
||||
fun read(context: Context, id: String): InputProfile {
|
||||
migrate(context)
|
||||
var result = InputProfile.defaults(id)
|
||||
val stored = prefs(context).getString("profile.$id", null) ?: return result
|
||||
val document = runCatching { JSONObject(stored) }.getOrNull() ?: return result
|
||||
if (document.optInt("version") != VERSION) return result
|
||||
val bindings = document.optJSONArray("bindings") ?: return result
|
||||
for (i in 0 until minOf(bindings.length(), 256)) {
|
||||
val entry = bindings.optJSONObject(i) ?: continue
|
||||
val role = runCatching { InputRole.valueOf(entry.getString("role")) }.getOrNull() ?: continue
|
||||
val control = InputControl.fromId(entry.optString("control")) ?: continue
|
||||
val action = decode(entry.optJSONObject("action") ?: continue) ?: continue
|
||||
if (action.validFor(control)) result = result.withBinding(role, control, action)
|
||||
}
|
||||
val keys = document.optJSONArray("keys")
|
||||
if (keys != null) result = result.copy(keys = (0 until minOf(keys.length(), 64)).mapNotNull { i ->
|
||||
runCatching {
|
||||
val item = keys.getJSONObject(i)
|
||||
val action = decode(item.getJSONObject("action")) ?: return@mapNotNull null
|
||||
if (!action.validFor(InputControl.A) || action is InputAction.Native && action.target != null) return@mapNotNull null
|
||||
val key = PhysicalInputKey(item.getString("device"), item.getString("name"), item.getInt("keyCode"), item.getInt("scanCode"))
|
||||
if (key.device.isBlank() || key.keyCode < 0 || key.scanCode < 0) return@mapNotNull null
|
||||
PhysicalInputBinding(key, action)
|
||||
}.getOrNull()
|
||||
}.distinctBy { Triple(it.key.device, it.key.keyCode, it.key.scanCode) })
|
||||
return result
|
||||
}
|
||||
|
||||
fun save(context: Context, profile: InputProfile) {
|
||||
val bindings = JSONArray()
|
||||
for ((role, controls) in profile.bindings) for ((control, action) in controls) {
|
||||
require(action.validFor(control))
|
||||
bindings.put(JSONObject().put("role", role.name).put("control", control.id).put("action", encode(action)))
|
||||
}
|
||||
val keys = JSONArray()
|
||||
for (binding in profile.keys) keys.put(JSONObject().put("device", binding.key.device).put("name", binding.key.name)
|
||||
.put("keyCode", binding.key.keyCode).put("scanCode", binding.key.scanCode).put("action", encode(binding.action)))
|
||||
prefs(context).edit().putString("profile.${profile.id}", JSONObject()
|
||||
.put("version", VERSION).put("id", profile.id).put("bindings", bindings).put("keys", keys).toString()).apply()
|
||||
}
|
||||
|
||||
fun reset(context: Context, id: String) { prefs(context).edit().remove("profile.$id").apply() }
|
||||
|
||||
fun resetAll(context: Context) {
|
||||
prefs(context).edit().clear().putBoolean("migrated", true).apply()
|
||||
}
|
||||
|
||||
private fun migrate(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.getBoolean("migrated", false)) return
|
||||
var game = InputProfile.defaults(InputProfile.GAME)
|
||||
val old = ControllerPrefs.read(context)
|
||||
for ((id, target) in old.mapping) {
|
||||
val source = InputControl.fromId(id) ?: continue
|
||||
val action = if (target == ControllerPrefs.OFF) InputAction.Disabled
|
||||
else InputAction.Native(InputControl.fromId(target) ?: continue)
|
||||
if (target != id) game = game.withBinding(InputRole.PRIMARY, source, action)
|
||||
}
|
||||
if (old.selectSteam) game = game.withBinding(InputRole.PHYSICAL, InputControl.SELECT, InputAction.Native(InputControl.GUIDE))
|
||||
if (!p.contains("profile.${InputProfile.GAME}")) save(context, game)
|
||||
p.edit().putBoolean("migrated", true).apply()
|
||||
}
|
||||
|
||||
private fun encode(action: InputAction): JSONObject = JSONObject().apply {
|
||||
when (action) {
|
||||
is InputAction.Native -> { put("type", "native"); action.target?.let { put("target", it.id) } }
|
||||
InputAction.Disabled -> put("type", "disabled")
|
||||
is InputAction.Keys -> { put("type", "keys"); put("codes", JSONArray(action.codes)) }
|
||||
is InputAction.MouseButton -> { put("type", "button"); put("code", action.code) }
|
||||
is InputAction.Command -> { put("type", "command"); put("command", action.command.name) }
|
||||
is InputAction.Directions -> { put("type", "directions"); put("codes", JSONArray(listOf(action.up, action.right, action.down, action.left))) }
|
||||
is InputAction.Pointer -> { put("type", "pointer"); put("speed", action.speed.toDouble()) }
|
||||
is InputAction.Scroll -> { put("type", "scroll"); put("speed", action.speed.toDouble()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(json: JSONObject): InputAction? = runCatching {
|
||||
when (json.getString("type")) {
|
||||
"native" -> InputAction.Native(if (json.has("target")) InputControl.fromId(json.getString("target")) ?: return null else null)
|
||||
"disabled" -> InputAction.Disabled
|
||||
"keys" -> InputAction.Keys(json.getJSONArray("codes").let { a -> (0 until a.length()).map { a.getInt(it) } })
|
||||
"button" -> InputAction.MouseButton(json.getInt("code"))
|
||||
"command" -> InputAction.Command(InputCommand.valueOf(json.getString("command")))
|
||||
"directions" -> json.getJSONArray("codes").let { InputAction.Directions(it.getInt(0), it.getInt(1), it.getInt(2), it.getInt(3)) }
|
||||
"pointer" -> InputAction.Pointer(json.getDouble("speed").toFloat())
|
||||
"scroll" -> InputAction.Scroll(json.getDouble("speed").toFloat())
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
interface InputOutput {
|
||||
fun key(code: Int, down: Boolean)
|
||||
fun button(code: Int, down: Boolean)
|
||||
fun pointer(dx: Float, dy: Float)
|
||||
fun scroll(steps: Int)
|
||||
fun command(command: InputCommand)
|
||||
fun requestMotion()
|
||||
}
|
||||
|
||||
class InputRouter(private val output: InputOutput) {
|
||||
private val keys = IntArray(256)
|
||||
private val buttons = IntArray(5)
|
||||
private val channels = ArrayList<Channel>()
|
||||
private val pads = ArrayList<PadSource>()
|
||||
private var profile = InputProfile.defaults(InputProfile.GAME)
|
||||
private var moving = false
|
||||
private var wheel = 0f
|
||||
private var enabled = true
|
||||
private val snapshots = ArrayList<() -> Unit>()
|
||||
|
||||
@Synchronized
|
||||
fun beforeProfileChange(snapshot: () -> Unit) { snapshots.add(snapshot) }
|
||||
|
||||
@Synchronized
|
||||
fun removeSnapshot(snapshot: () -> Unit) { snapshots.remove(snapshot) }
|
||||
|
||||
@Synchronized
|
||||
fun setEnabled(on: Boolean) {
|
||||
if (enabled == on) return
|
||||
if (!on) releaseAll()
|
||||
enabled = on
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setProfile(next: InputProfile) {
|
||||
if (profile == next) return
|
||||
for (snapshot in snapshots) snapshot()
|
||||
for (pad in pads) pad.snapshot()
|
||||
profile = next
|
||||
for (channel in channels) channel.configure(next.action(channel.role, channel.control))
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
wheel = 0f
|
||||
moving = false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pad(role: InputRole): PadSource = PadSource(role).also { pads.add(it) }
|
||||
|
||||
@Synchronized
|
||||
fun channel(role: InputRole, control: InputControl, fixedAction: InputAction? = null): Channel = Channel(role, control, fixedAction).also {
|
||||
channels.add(it)
|
||||
it.configure(profile.action(role, control))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
for (snapshot in snapshots) snapshot()
|
||||
for (pad in pads) pad.snapshot()
|
||||
for (channel in channels) channel.suspend()
|
||||
for (pad in pads) pad.refreshIdentity()
|
||||
wheel = 0f
|
||||
moving = false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun tick(seconds: Float): Boolean {
|
||||
if (!enabled) return false
|
||||
var x = 0f
|
||||
var y = 0f
|
||||
var scroll = 0f
|
||||
for (channel in channels) {
|
||||
if (channel.blocked || channel.control.isTrackpad) continue
|
||||
val cx = if (abs(channel.x) > 0.12f) channel.x else 0f
|
||||
val cy = if (abs(channel.y) > 0.12f) channel.y else 0f
|
||||
when (val action = channel.action) {
|
||||
is InputAction.Pointer -> { x += cx * action.speed; y += cy * action.speed }
|
||||
is InputAction.Scroll -> scroll -= cy * action.speed
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
val dt = seconds.coerceIn(0f, 0.05f)
|
||||
if (x != 0f || y != 0f) output.pointer(x * dt, y * dt)
|
||||
wheel += scroll * dt
|
||||
flushWheel()
|
||||
moving = x != 0f || y != 0f || scroll != 0f
|
||||
return moving
|
||||
}
|
||||
|
||||
private fun flushWheel() {
|
||||
val steps = wheel.toInt()
|
||||
if (steps != 0) { wheel -= steps; output.scroll(steps) }
|
||||
}
|
||||
|
||||
private fun key(code: Int, down: Boolean) {
|
||||
val was = keys[code]
|
||||
keys[code] = (was + if (down) 1 else -1).coerceAtLeast(0)
|
||||
if ((was == 0) != (keys[code] == 0)) output.key(code, down)
|
||||
}
|
||||
|
||||
private fun button(code: Int, down: Boolean) {
|
||||
val at = code - 272
|
||||
val was = buttons[at]
|
||||
buttons[at] = (was + if (down) 1 else -1).coerceAtLeast(0)
|
||||
if ((was == 0) != (buttons[at] == 0)) output.button(code, down)
|
||||
}
|
||||
|
||||
inner class Channel internal constructor(val role: InputRole, val control: InputControl, private val fixedAction: InputAction?) {
|
||||
var action: InputAction = InputAction.Native()
|
||||
private set
|
||||
internal var x = 0f
|
||||
internal var y = 0f
|
||||
internal var contact = false
|
||||
internal var blocked = false
|
||||
private var held = false
|
||||
private val directions = BooleanArray(4)
|
||||
|
||||
internal fun configure(next: InputAction) {
|
||||
suspend()
|
||||
action = (fixedAction ?: next).takeIf { it.validFor(control) } ?: InputAction.Native()
|
||||
}
|
||||
|
||||
internal fun observe(value: Float, second: Float = 0f, touching: Boolean = false) {
|
||||
x = value; y = second; contact = touching
|
||||
}
|
||||
|
||||
internal fun nativeIdentity(): Boolean = enabled && !blocked && (action as? InputAction.Native)?.target == null && action is InputAction.Native
|
||||
|
||||
internal fun suspend() {
|
||||
releaseOutputs()
|
||||
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
|
||||
}
|
||||
|
||||
fun release() = synchronized(this@InputRouter) {
|
||||
releaseOutputs()
|
||||
x = 0f; y = 0f; contact = false; blocked = false
|
||||
}
|
||||
|
||||
fun close() = synchronized(this@InputRouter) {
|
||||
release()
|
||||
channels.remove(this)
|
||||
}
|
||||
|
||||
fun update(value: Float, second: Float = 0f, touching: Boolean = false): InputControl? = synchronized(this@InputRouter) {
|
||||
val oldX = x
|
||||
val oldY = y
|
||||
val oldContact = contact
|
||||
x = value.coerceIn(-1f, 1f)
|
||||
y = second.coerceIn(-1f, 1f)
|
||||
contact = touching
|
||||
if (!enabled) {
|
||||
blocked = contact || abs(x) > 0.12f || abs(y) > 0.12f
|
||||
return@synchronized null
|
||||
}
|
||||
if (blocked) {
|
||||
if (!contact && abs(x) <= 0.12f && abs(y) <= 0.12f) blocked = false
|
||||
return@synchronized null
|
||||
}
|
||||
when (val a = action) {
|
||||
is InputAction.Native -> return@synchronized a.target ?: control
|
||||
is InputAction.Disabled -> Unit
|
||||
is InputAction.Pointer -> {
|
||||
if (control.isTrackpad) {
|
||||
if (contact && oldContact) output.pointer((x - oldX) * a.speed, -(y - oldY) * a.speed)
|
||||
} else requestMotion()
|
||||
}
|
||||
is InputAction.Scroll -> {
|
||||
if (control.isTrackpad) {
|
||||
if (contact && oldContact) { wheel += (y - oldY) * a.speed; flushWheel() }
|
||||
} else requestMotion()
|
||||
}
|
||||
is InputAction.Directions -> {
|
||||
val vertical = if (control.isTrackpad) -y else y
|
||||
direction(0, -vertical, a.up); direction(1, x, a.right)
|
||||
direction(2, vertical, a.down); direction(3, -x, a.left)
|
||||
}
|
||||
else -> {
|
||||
val down = if (control.isTrackpad) contact else maxOf(abs(x), abs(y)) > if (held) 0.35f else 0.55f
|
||||
if (held != down) {
|
||||
held = down
|
||||
when (a) {
|
||||
is InputAction.Keys -> if (down) a.codes.forEach { key(it, true) } else a.codes.asReversed().forEach { key(it, false) }
|
||||
is InputAction.MouseButton -> button(a.code, down)
|
||||
is InputAction.Command -> if (down) output.command(a.command)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun requestMotion() {
|
||||
if (!moving && (abs(x) > 0.12f || abs(y) > 0.12f)) { moving = true; output.requestMotion() }
|
||||
}
|
||||
|
||||
private fun direction(at: Int, value: Float, code: Int) {
|
||||
val down = value > if (directions[at]) 0.35f else 0.55f
|
||||
if (directions[at] != down) { directions[at] = down; key(code, down) }
|
||||
}
|
||||
|
||||
private fun releaseOutputs() {
|
||||
when (val a = action) {
|
||||
is InputAction.Keys -> if (held) a.codes.asReversed().forEach { key(it, false) }
|
||||
is InputAction.MouseButton -> if (held) button(a.code, false)
|
||||
is InputAction.Directions -> {
|
||||
val codes = intArrayOf(a.up, a.right, a.down, a.left)
|
||||
for (i in directions.indices) if (directions[i]) key(codes[i], false)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
held = false
|
||||
directions.fill(false)
|
||||
}
|
||||
}
|
||||
|
||||
inner class PadSource internal constructor(role: InputRole) {
|
||||
private val routes = Array(InputControl.padControls.size) { channel(role, InputControl.padControls[it]) }
|
||||
private val result = PadState()
|
||||
private var last: PadState? = null
|
||||
private var identity = true
|
||||
private var nativeCandidate = true
|
||||
|
||||
init { refreshIdentity() }
|
||||
|
||||
internal fun refreshIdentity() {
|
||||
nativeCandidate = routes.all { (it.action as? InputAction.Native)?.target == null && it.action is InputAction.Native }
|
||||
identity = enabled && nativeCandidate && routes.none { it.blocked }
|
||||
}
|
||||
|
||||
internal fun snapshot() {
|
||||
val raw = last ?: return
|
||||
for (route in routes) {
|
||||
route.x = raw.value(route.control)
|
||||
route.y = raw.secondValue(route.control)
|
||||
}
|
||||
}
|
||||
|
||||
fun route(raw: PadState): PadState = synchronized(this@InputRouter) {
|
||||
last = raw
|
||||
if (identity) return@synchronized raw
|
||||
result.clear()
|
||||
for (route in routes) {
|
||||
val x = raw.value(route.control)
|
||||
val y = raw.secondValue(route.control)
|
||||
val target = route.update(x, y) ?: continue
|
||||
result.add(target, x, y)
|
||||
}
|
||||
if (nativeCandidate && enabled && routes.none { it.blocked }) identity = true
|
||||
result
|
||||
}
|
||||
|
||||
fun release() = synchronized(this@InputRouter) {
|
||||
for (route in routes) route.release()
|
||||
last = null
|
||||
result.clear()
|
||||
refreshIdentity()
|
||||
}
|
||||
|
||||
fun close() = synchronized(this@InputRouter) {
|
||||
release()
|
||||
for (route in routes) route.close()
|
||||
pads.remove(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val padButtons = intArrayOf(PadState.A, PadState.B, PadState.X, PadState.Y, PadState.LB, PadState.RB,
|
||||
PadState.SELECT, PadState.START, PadState.L3, PadState.R3, PadState.GUIDE, PadState.QAM)
|
||||
|
||||
internal fun PadState.value(control: InputControl): Float = when (control) {
|
||||
InputControl.UP -> if (up) 1f else 0f
|
||||
InputControl.RIGHT -> if (right) 1f else 0f
|
||||
InputControl.DOWN -> if (down) 1f else 0f
|
||||
InputControl.LEFT -> if (left) 1f else 0f
|
||||
InputControl.LT -> leftTrigger
|
||||
InputControl.RT -> rightTrigger
|
||||
InputControl.LS -> leftX
|
||||
InputControl.RS -> rightX
|
||||
else -> if (control.ordinal < padButtons.size && isDown(padButtons[control.ordinal])) 1f else 0f
|
||||
}
|
||||
|
||||
internal fun PadState.secondValue(control: InputControl): Float = when (control) {
|
||||
InputControl.LS -> leftY
|
||||
InputControl.RS -> rightY
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
internal fun PadState.add(control: InputControl, x: Float, y: Float = 0f) {
|
||||
when (control) {
|
||||
InputControl.UP -> up = up || x > 0.5f
|
||||
InputControl.RIGHT -> right = right || x > 0.5f
|
||||
InputControl.DOWN -> down = down || x > 0.5f
|
||||
InputControl.LEFT -> left = left || x > 0.5f
|
||||
InputControl.LT -> leftTrigger = maxOf(leftTrigger, x)
|
||||
InputControl.RT -> rightTrigger = maxOf(rightTrigger, x)
|
||||
InputControl.LS -> if (x * x + y * y > leftX * leftX + leftY * leftY) { leftX = x; leftY = y }
|
||||
InputControl.RS -> if (x * x + y * y > rightX * rightX + rightY * rightY) { rightX = x; rightY = y }
|
||||
else -> if (control.ordinal < padButtons.size && x > 0.5f) press(padButtons[control.ordinal], true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class InputSurfaceElement(val control: InputControl, val x: Float, val y: Float, val width: Float, val height: Float) {
|
||||
fun bounded() = copy(x = x.coerceIn(0f, 0.9f), y = y.coerceIn(0f, 0.9f),
|
||||
width = width.coerceIn(0.1f, 1f - x.coerceIn(0f, 0.9f)), height = height.coerceIn(0.1f, 1f - y.coerceIn(0f, 0.9f)))
|
||||
}
|
||||
|
||||
object InputSurfaceLayout {
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE)
|
||||
|
||||
fun defaults() = listOf(
|
||||
InputSurfaceElement(InputControl.LEFT_PAD, 0.02f, 0.02f, 0.46f, 0.65f),
|
||||
InputSurfaceElement(InputControl.RIGHT_PAD, 0.52f, 0.02f, 0.46f, 0.65f),
|
||||
InputSurfaceElement(InputControl.A, 0.02f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.B, 0.26f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.SELECT, 0.52f, 0.72f, 0.20f, 0.25f),
|
||||
InputSurfaceElement(InputControl.START, 0.76f, 0.72f, 0.22f, 0.25f),
|
||||
)
|
||||
|
||||
fun read(context: Context, profile: String): List<InputSurfaceElement> {
|
||||
val text = prefs(context).getString(profile, null) ?: return defaults()
|
||||
return runCatching {
|
||||
val doc = JSONObject(text)
|
||||
require(doc.getInt("version") == 1)
|
||||
val elements = doc.getJSONArray("elements")
|
||||
(0 until minOf(elements.length(), 28)).mapNotNull { i ->
|
||||
val item = elements.optJSONObject(i) ?: return@mapNotNull null
|
||||
val control = InputControl.fromId(item.optString("control")) ?: return@mapNotNull null
|
||||
val values = listOf("x", "y", "w", "h").map { item.optDouble(it, Double.NaN).toFloat() }
|
||||
if (values.any { !it.isFinite() }) return@mapNotNull null
|
||||
InputSurfaceElement(control, values[0], values[1], values[2], values[3]).bounded()
|
||||
}.distinctBy { it.control }
|
||||
}.getOrElse { defaults() }
|
||||
}
|
||||
|
||||
fun save(context: Context, profile: String, layout: List<InputSurfaceElement>) {
|
||||
val array = JSONArray()
|
||||
for (element in layout.distinctBy { it.control }.take(28)) {
|
||||
val e = element.bounded()
|
||||
array.put(JSONObject().put("control", e.control.id).put("x", e.x.toDouble()).put("y", e.y.toDouble())
|
||||
.put("w", e.width.toDouble()).put("h", e.height.toDouble()))
|
||||
}
|
||||
prefs(context).edit().putString(profile, JSONObject().put("version", 1).put("elements", array).toString()).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
object InputTiming {
|
||||
@JvmField @Volatile var enabled = false
|
||||
private val samples = LongArray(4096)
|
||||
private var count = 0L
|
||||
private var next = 0
|
||||
|
||||
@JvmStatic fun begin(): Long = if (enabled) System.nanoTime() else 0L
|
||||
|
||||
@JvmStatic fun end(start: Long) {
|
||||
if (start == 0L) return
|
||||
val elapsed = System.nanoTime() - start
|
||||
synchronized(this) {
|
||||
samples[next] = elapsed
|
||||
next = (next + 1) % samples.size
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun reset() { count = 0; next = 0; samples.fill(0) }
|
||||
|
||||
@Synchronized fun report(): String {
|
||||
val size = minOf(count, samples.size.toLong()).toInt()
|
||||
if (size == 0) return "No input samples"
|
||||
val values = samples.copyOf(size).sortedArray()
|
||||
fun percentile(p: Double) = values[((size - 1) * p).toInt()] / 1000.0
|
||||
return String.format(Locale.ROOT, "Dispatch to publication (µs): p50 %.1f, p95 %.1f, p99 %.1f; %d samples, last %d retained",
|
||||
percentile(.5), percentile(.95), percentile(.99), count, size)
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ class OnScreenControls(
|
||||
}
|
||||
|
||||
private fun applySettings() {
|
||||
for (control in controls) control.target = settings.mapping[control.id] ?: control.id
|
||||
for (control in controls) control.target = if (pad?.hasInputRouter() == true) control.id else settings.mapping[control.id] ?: control.id
|
||||
val tint = settings.tint
|
||||
val alpha = settings.opacity / 100f
|
||||
fun shade(a: Int, f: Float) = Color.argb((a * alpha).toInt(), (Color.red(tint) * f).toInt(), (Color.green(tint) * f).toInt(), (Color.blue(tint) * f).toInt())
|
||||
@@ -210,6 +210,7 @@ class OnScreenControls(
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
releaseAll()
|
||||
pad?.releaseTouch(this)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
@@ -673,12 +674,27 @@ class OnScreenControls(
|
||||
private fun apply() {
|
||||
val bridge = pad
|
||||
if (bridge == null || editing) { invalidate(); return }
|
||||
if (bridge.hasInputRouter()) {
|
||||
bridge.applyTouch(this, InputRole.PRIMARY) { state ->
|
||||
state.clear()
|
||||
for (control in controls) {
|
||||
if (!isVisible(control)) continue
|
||||
if (control.stick >= 0) {
|
||||
if (control.stick == 0) { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius }
|
||||
else { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius }
|
||||
if (settings.stickClick) state.press(if (control.stick == 0) PadState.L3 else PadState.R3, control.clicked)
|
||||
} else write(state, control.id, control.pressedBy != -1)
|
||||
}
|
||||
}
|
||||
invalidate()
|
||||
return
|
||||
}
|
||||
val qamHeld = controls.any { it.id == "qam" && it.pressedBy != -1 }
|
||||
if (buttonsOnly) {
|
||||
bridge.setSystemButtons(controls.any { it.id == "guide" && it.pressedBy != -1 }, qamHeld)
|
||||
} else {
|
||||
bridge.setSystemButtons(false, qamHeld)
|
||||
bridge.applyTouch { state ->
|
||||
bridge.applyTouch(this, InputRole.PRIMARY) { state ->
|
||||
val used = HashSet<String>()
|
||||
val held = HashSet<String>()
|
||||
for (control in controls) {
|
||||
@@ -718,6 +734,7 @@ class OnScreenControls(
|
||||
"l3" -> state.press(PadState.L3, down)
|
||||
"r3" -> state.press(PadState.R3, down)
|
||||
"guide" -> state.press(PadState.GUIDE, down)
|
||||
"qam" -> state.press(PadState.QAM, down)
|
||||
"lt" -> state.leftTrigger = if (down) 1f else 0f
|
||||
"rt" -> state.rightTrigger = if (down) 1f else 0f
|
||||
"up" -> state.up = down
|
||||
|
||||
@@ -52,6 +52,19 @@ public final class PadBridge {
|
||||
private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS];
|
||||
private final PadState[] playerStates = new PadState[SLOTS];
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final PadState combinedState = new PadState();
|
||||
private InputRouter inputRouter;
|
||||
private final InputRouter.PadSource[] physicalRoutes = new InputRouter.PadSource[SLOTS];
|
||||
private final java.util.IdentityHashMap<Object, TouchSource> touchSources = new java.util.IdentityHashMap<>();
|
||||
private final Object defaultTouchSource = new Object();
|
||||
|
||||
private static final class TouchSource {
|
||||
final PadState raw = new PadState();
|
||||
final PadState passthrough = new PadState();
|
||||
final InputRole role;
|
||||
InputRouter.PadSource route;
|
||||
TouchSource(InputRole role) { this.role = role; }
|
||||
}
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
private volatile boolean selectSteam;
|
||||
@@ -64,6 +77,9 @@ public final class PadBridge {
|
||||
private int chordButton = -1;
|
||||
private boolean chordButtonPressed;
|
||||
private boolean qamTapPressed;
|
||||
private boolean guideTapPressed;
|
||||
private int guideTapGeneration;
|
||||
private boolean routedQamPressed;
|
||||
private int qamChordGeneration;
|
||||
private volatile Runnable onPlayerInput;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
@@ -107,6 +123,32 @@ public final class PadBridge {
|
||||
selectSteam = on;
|
||||
}
|
||||
|
||||
public synchronized void setInputRouter(InputRouter router) {
|
||||
if (inputRouter == router) return;
|
||||
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
|
||||
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
|
||||
inputRouter = router;
|
||||
for (int slot = 0; slot < SLOTS; slot++) physicalRoutes[slot] = router == null ? null : router.pad(InputRole.PHYSICAL);
|
||||
for (TouchSource source : touchSources.values()) source.route = router == null ? null : router.pad(source.role);
|
||||
}
|
||||
|
||||
public synchronized boolean hasInputRouter() { return inputRouter != null; }
|
||||
|
||||
public synchronized void setInputProfile(InputProfile profile) {
|
||||
if (inputRouter == null) return;
|
||||
inputRouter.setProfile(profile);
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
|
||||
}
|
||||
|
||||
public synchronized void setInputEnabled(boolean enabled) {
|
||||
if (inputRouter == null) return;
|
||||
inputRouter.setEnabled(enabled);
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) if (playerWriters[slot] != null) publish(slot);
|
||||
DeckControls.INSTANCE.refresh();
|
||||
}
|
||||
|
||||
public void setExcluded(java.util.function.IntPredicate devices) {
|
||||
excluded = devices;
|
||||
}
|
||||
@@ -129,9 +171,15 @@ public final class PadBridge {
|
||||
|
||||
private void resetBuiltin() {
|
||||
routedTouch.clear();
|
||||
for (TouchSource source : touchSources.values()) {
|
||||
source.raw.clear();
|
||||
if (source.route != null) source.route.release();
|
||||
}
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamTapPressed = false;
|
||||
guideTapPressed = false;
|
||||
guideTapGeneration++;
|
||||
qamChordActive = false;
|
||||
chordButtonPressed = false;
|
||||
qamChordGeneration++;
|
||||
@@ -149,6 +197,14 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
guideTapPressed = false;
|
||||
guideTapGeneration++;
|
||||
if (inputRouter != null) inputRouter.releaseAll();
|
||||
for (TouchSource source : touchSources.values()) if (source.route != null) source.route.close();
|
||||
touchSources.clear();
|
||||
for (InputRouter.PadSource route : physicalRoutes) if (route != null) route.close();
|
||||
Arrays.fill(physicalRoutes, null);
|
||||
inputRouter = null;
|
||||
releaseBackPaddles();
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
@@ -287,6 +343,7 @@ public final class PadBridge {
|
||||
if (slot < 0) return false;
|
||||
int side = JoyCon.side(event.getDevice());
|
||||
if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true;
|
||||
long timing = InputTiming.begin();
|
||||
PadState pad = stateFor(slot);
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
@@ -297,7 +354,7 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
if (selectSteam) pad.press(PadState.GUIDE, pressed);
|
||||
if (selectSteam && inputRouter == null) pad.press(PadState.GUIDE, pressed);
|
||||
else pad.press(6, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
|
||||
@@ -326,6 +383,7 @@ public final class PadBridge {
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -334,6 +392,7 @@ public final class PadBridge {
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
if (slot < 0) return false;
|
||||
long timing = InputTiming.begin();
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
statAxes++;
|
||||
@@ -353,6 +412,7 @@ public final class PadBridge {
|
||||
}
|
||||
if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput();
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
pad.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
@@ -386,6 +446,7 @@ public final class PadBridge {
|
||||
notePlayerInput();
|
||||
}
|
||||
publish(slot);
|
||||
InputTiming.end(timing);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -407,32 +468,33 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
applyTouch(defaultTouchSource, InputRole.PRIMARY, mutation);
|
||||
}
|
||||
|
||||
public synchronized void applyTouch(Object owner, InputRole role, java.util.function.Consumer<PadState> mutation) {
|
||||
if (!builtinPad) return;
|
||||
if (touchRoute != null && touchRoute.active()) {
|
||||
mutation.accept(routedTouch);
|
||||
touchRoute.accept(routedTouch);
|
||||
boolean changed = false;
|
||||
for (int button : ROUTED_PASS_THROUGH) {
|
||||
boolean down = routedTouch.isDown(button);
|
||||
if (state.isDown(button) == down) continue;
|
||||
state.press(button, down);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
publish();
|
||||
}
|
||||
return;
|
||||
long timing = InputTiming.begin();
|
||||
TouchSource source = touchSources.get(owner);
|
||||
if (source == null) {
|
||||
source = new TouchSource(role);
|
||||
if (inputRouter != null) source.route = inputRouter.pad(role);
|
||||
touchSources.put(owner, source);
|
||||
}
|
||||
routedTouch.clear();
|
||||
mutation.accept(state);
|
||||
mutation.accept(source.raw);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
InputTiming.end(timing);
|
||||
}
|
||||
|
||||
public synchronized void releaseTouch(Object owner) {
|
||||
TouchSource source = touchSources.remove(owner);
|
||||
if (source == null) return;
|
||||
if (source.route != null) source.route.close();
|
||||
publish();
|
||||
}
|
||||
|
||||
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
|
||||
@@ -457,13 +519,24 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void releaseAll() {
|
||||
guideTapGeneration++;
|
||||
routedQamPressed = false;
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
guideTapPressed = false;
|
||||
qamTapPressed = false;
|
||||
qamChordActive = false;
|
||||
chordButtonPressed = false;
|
||||
qamChordGeneration++;
|
||||
if (inputRouter != null) inputRouter.releaseAll();
|
||||
if (inputRouter == null) for (TouchSource source : touchSources.values()) source.raw.clear();
|
||||
routedTouch.clear();
|
||||
releaseBackPaddles();
|
||||
state.clear();
|
||||
DeckControls.INSTANCE.suspend();
|
||||
if (inputRouter == null) state.clear();
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
if (playerWriters[slot] == null) continue;
|
||||
playerStates[slot].clear();
|
||||
if (inputRouter == null) playerStates[slot].clear();
|
||||
publish(slot);
|
||||
}
|
||||
}
|
||||
@@ -491,6 +564,20 @@ public final class PadBridge {
|
||||
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
|
||||
}
|
||||
|
||||
public synchronized void triggerSteamMenu() {
|
||||
if (!builtinPad || guideTapPressed) return;
|
||||
guideTapPressed = true;
|
||||
int generation = ++guideTapGeneration;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> {
|
||||
synchronized (PadBridge.this) {
|
||||
if (generation != guideTapGeneration) return;
|
||||
guideTapPressed = false;
|
||||
publish();
|
||||
}
|
||||
}, 90);
|
||||
}
|
||||
|
||||
private synchronized void releaseQamTap(int generation) {
|
||||
if (generation != qamChordGeneration || !qamTapPressed) return;
|
||||
qamTapPressed = false;
|
||||
@@ -606,6 +693,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
private void free(int slot) {
|
||||
if (physicalRoutes[slot] != null) physicalRoutes[slot].release();
|
||||
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
partners[slot] = NO_CONTROLLER;
|
||||
@@ -658,6 +746,7 @@ public final class PadBridge {
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
PadState output = physicalRoutes[slot] == null ? playerStates[slot] : physicalRoutes[slot].route(playerStates[slot]);
|
||||
FakeInputWriter player = playerWriters[slot];
|
||||
if (player == null) {
|
||||
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
|
||||
@@ -667,22 +756,47 @@ public final class PadBridge {
|
||||
}
|
||||
playerWriters[slot] = player;
|
||||
}
|
||||
player.writePad(playerStates[slot]);
|
||||
player.writePad(output);
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return;
|
||||
PadState output = physicalRoutes[0] == null ? state : physicalRoutes[0].route(state);
|
||||
if (!touchSources.isEmpty()) {
|
||||
combinedState.copyFrom(output);
|
||||
routedTouch.clear();
|
||||
boolean epic = touchRoute != null && touchRoute.active();
|
||||
for (TouchSource source : touchSources.values()) {
|
||||
PadState touch = source.route == null ? source.raw : source.route.route(source.raw);
|
||||
if (epic) {
|
||||
routedTouch.merge(touch);
|
||||
source.passthrough.clear();
|
||||
for (int button : ROUTED_PASS_THROUGH) source.passthrough.press(button, touch.isDown(button));
|
||||
combinedState.merge(source.passthrough);
|
||||
} else combinedState.merge(touch);
|
||||
}
|
||||
if (epic) touchRoute.accept(routedTouch);
|
||||
output = combinedState;
|
||||
}
|
||||
boolean guide = systemGuidePressed || qamChordActive || guideTapPressed;
|
||||
boolean routedQam = output.isDown(PadState.QAM);
|
||||
boolean routedQamStarted = routedQam && !routedQamPressed;
|
||||
routedQamPressed = routedQam;
|
||||
if (routedQamStarted && !nativeQam()) {
|
||||
startChord(PadState.A);
|
||||
return;
|
||||
}
|
||||
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed);
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
return;
|
||||
}
|
||||
boolean guide = systemGuidePressed || qamChordActive;
|
||||
boolean qam = nativeQam() && (systemQamPressed || qamTapPressed);
|
||||
if (!guide && !qam) {
|
||||
writer.writePad(state);
|
||||
if (!guide && !qam && (!routedQam || nativeQam())) {
|
||||
writer.writePad(output);
|
||||
return;
|
||||
}
|
||||
effectiveState.copyFrom(state);
|
||||
effectiveState.copyFrom(output);
|
||||
if (!nativeQam()) effectiveState.press(PadState.QAM, false);
|
||||
if (guide) effectiveState.press(PadState.GUIDE, true);
|
||||
if (qam) effectiveState.press(PadState.QAM, true);
|
||||
if (chordButtonPressed) effectiveState.press(chordButton, true);
|
||||
|
||||
@@ -36,6 +36,19 @@ class PadState {
|
||||
buttons = 0
|
||||
}
|
||||
|
||||
fun merge(other: PadState) {
|
||||
buttons = buttons or other.buttons
|
||||
up = up || other.up; right = right || other.right; down = down || other.down; left = left || other.left
|
||||
leftTrigger = maxOf(leftTrigger, other.leftTrigger)
|
||||
rightTrigger = maxOf(rightTrigger, other.rightTrigger)
|
||||
if (other.leftX * other.leftX + other.leftY * other.leftY > leftX * leftX + leftY * leftY) {
|
||||
leftX = other.leftX; leftY = other.leftY
|
||||
}
|
||||
if (other.rightX * other.rightX + other.rightY * other.rightY > rightX * rightX + rightY * rightY) {
|
||||
rightX = other.rightX; rightY = other.rightY
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val A = 0
|
||||
const val B = 1
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
data class PcKey(
|
||||
val label: String,
|
||||
val code: Int,
|
||||
val weight: Float = 1f,
|
||||
val shifted: String? = null,
|
||||
@StringRes val labelRes: Int = 0,
|
||||
)
|
||||
|
||||
internal fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted)
|
||||
|
||||
internal fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes)
|
||||
|
||||
val PC_KEY_ROWS: List<List<PcKey>> = listOf(
|
||||
listOf(
|
||||
k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
|
||||
k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88),
|
||||
k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109),
|
||||
),
|
||||
listOf(
|
||||
k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"),
|
||||
k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"),
|
||||
k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"),
|
||||
k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
|
||||
k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"),
|
||||
k("\\", 43, 1.5f, shifted = "|"),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
|
||||
k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""),
|
||||
k(R.string.pckb_key_enter, 28, 2.25f),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
|
||||
k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"),
|
||||
k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f),
|
||||
k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
|
||||
),
|
||||
)
|
||||
|
||||
val PC_MODIFIERS: Set<Int> = setOf(42, 54, 29, 97, 56, 100, 125)
|
||||
|
||||
val PC_KEYS: Map<Int, PcKey> = PC_KEY_ROWS.flatten().associateBy { it.code }
|
||||
|
||||
fun pcKeyLabel(context: Context, code: Int): String {
|
||||
val key = PC_KEYS[code] ?: return code.toString()
|
||||
val text = if (key.labelRes != 0) context.getString(key.labelRes) else key.label
|
||||
return if (text.length == 1 && text[0].isLetter()) text.uppercase() else text
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
|
||||
data class PhysicalInputKey(val device: String, val name: String, val keyCode: Int, val scanCode: Int) {
|
||||
val label: String get() = "$name · ${KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_")} ($scanCode)"
|
||||
|
||||
companion object {
|
||||
fun identity(device: InputDevice) = "${device.vendorId}:${device.productId}:${device.descriptor.ifBlank { device.name }}"
|
||||
|
||||
fun capture(event: KeyEvent): PhysicalInputKey? {
|
||||
val device = event.device ?: return null
|
||||
if (device.isVirtual || PadBridge.isSystemVirtualInputName(device.name)) return null
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||
KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_POWER, KeyEvent.KEYCODE_SLEEP,
|
||||
KeyEvent.KEYCODE_WAKEUP, KeyEvent.KEYCODE_APP_SWITCH -> return null
|
||||
}
|
||||
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN && event.scanCode <= 0) return null
|
||||
return PhysicalInputKey(identity(device), device.name, event.keyCode, event.scanCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PhysicalInputBinding(val key: PhysicalInputKey, val action: InputAction)
|
||||
|
||||
class PhysicalInputBindings(private val router: InputRouter) {
|
||||
private class Entry(val binding: PhysicalInputBinding, val channel: InputRouter.Channel) {
|
||||
var heldDevice = -1
|
||||
var suppressed = false
|
||||
}
|
||||
private var entries = emptyList<Entry>()
|
||||
private val released = ArrayList<Entry>()
|
||||
private val devices = HashMap<Int, String>()
|
||||
|
||||
fun configure(bindings: List<PhysicalInputBinding>) {
|
||||
if (entries.map { it.binding } == bindings) return
|
||||
for (entry in entries) {
|
||||
entry.channel.close()
|
||||
if (entry.heldDevice != -1) released.add(entry)
|
||||
}
|
||||
entries = bindings.map { Entry(it, router.channel(InputRole.PHYSICAL, InputControl.A, it.action)) }
|
||||
}
|
||||
|
||||
fun onKey(event: KeyEvent, enabled: Boolean): Boolean {
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
for (i in released.indices.reversed()) {
|
||||
val old = released[i]
|
||||
if (old.heldDevice == event.deviceId && old.binding.key.keyCode == event.keyCode && old.binding.key.scanCode == event.scanCode) {
|
||||
if (event.action == KeyEvent.ACTION_UP) released.removeAt(i)
|
||||
return true
|
||||
}
|
||||
}
|
||||
for (entry in entries) {
|
||||
val key = entry.binding.key
|
||||
if (key.keyCode != event.keyCode || key.scanCode != event.scanCode) continue
|
||||
if (entry.heldDevice != -1 && entry.heldDevice == event.deviceId) {
|
||||
if (event.action == KeyEvent.ACTION_UP) {
|
||||
entry.channel.update(0f); entry.heldDevice = -1; entry.suppressed = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!enabled || event.action != KeyEvent.ACTION_DOWN || entry.binding.action is InputAction.Native) continue
|
||||
val identity = devices[event.deviceId] ?: event.device?.let { PhysicalInputKey.identity(it) }?.also { devices[event.deviceId] = it } ?: continue
|
||||
if (identity != key.device) continue
|
||||
entry.heldDevice = event.deviceId
|
||||
entry.suppressed = event.repeatCount != 0
|
||||
if (!entry.suppressed) entry.channel.update(1f)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun release() {
|
||||
for (entry in entries) {
|
||||
entry.channel.release()
|
||||
if (entry.heldDevice != -1) entry.suppressed = true
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnected(id: Int) {
|
||||
devices.remove(id)
|
||||
released.removeAll { it.heldDevice == id }
|
||||
for (entry in entries) if (entry.heldDevice == id) { entry.channel.release(); entry.heldDevice = -1 }
|
||||
}
|
||||
|
||||
fun close() { entries.forEach { it.channel.close() }; entries = emptyList(); released.clear(); devices.clear() }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
|
||||
TERMINAL("terminal", R.string.second_mode_terminal),
|
||||
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
|
||||
CUSTOM_CONTROLS("custom-controls", R.string.input_custom_controls),
|
||||
}
|
||||
|
||||
data class SecondScreenDisplay(val id: Int, val label: String)
|
||||
|
||||
@@ -20,7 +20,8 @@ import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class SteamControllerBle(private val context: Context) {
|
||||
class SteamControllerBle(private val context: Context, router: InputRouter? = null) {
|
||||
private val routedInput = router?.let(::TritonInputRouter)
|
||||
private val thread = HandlerThread("steam-controller-ble").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val outputReports = mutableMapOf<Int, UUID>()
|
||||
@@ -47,6 +48,7 @@ class SteamControllerBle(private val context: Context) {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
routedInput?.close()
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
@@ -217,10 +219,13 @@ class SteamControllerBle(private val context: Context) {
|
||||
|
||||
private fun onState(data: ByteArray) {
|
||||
if (data.size < STATE_BYTES) return
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, data)
|
||||
val timing = InputTiming.begin()
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, routedInput?.route(data) ?: data)
|
||||
InputTiming.end(timing)
|
||||
}
|
||||
|
||||
private fun release() {
|
||||
routedInput?.release()
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class TritonInputRouter(private val router: InputRouter) {
|
||||
private val channels = InputControl.entries.map { router.channel(InputRole.PHYSICAL, it) }.toTypedArray()
|
||||
private var last: ByteArray? = null
|
||||
private var buffer = ByteArray(45)
|
||||
private val snapshot: () -> Unit = { last?.let { read(it, false) } }
|
||||
|
||||
init { router.beforeProfileChange(snapshot) }
|
||||
|
||||
fun route(data: ByteArray): ByteArray = synchronized(router) {
|
||||
if (data.size < 45) return@synchronized data
|
||||
last = data
|
||||
if (channels.all { it.nativeIdentity() }) return@synchronized data
|
||||
if (buffer.size != data.size) buffer = ByteArray(data.size)
|
||||
data.copyInto(buffer)
|
||||
putBits(buffer, bits(data) and CONTROL_BITS.inv())
|
||||
for (offset in 5..27 step 2) putShort(buffer, offset, 0)
|
||||
read(data, true)
|
||||
buffer
|
||||
}
|
||||
|
||||
private fun read(data: ByteArray, emit: Boolean) {
|
||||
val bits = bits(data)
|
||||
for (control in InputControl.entries) {
|
||||
val channel = channels[control.ordinal]
|
||||
val offset = offset(control)
|
||||
val pair = control.dimensions == 2
|
||||
val x = if (offset != 0) axis(data, offset) else if (bits and bit(control) != 0) 1f else 0f
|
||||
val y = if (pair) axis(data, offset + 2) * if (control.isTrackpad) 1f else -1f else 0f
|
||||
val contact = control.isTrackpad && bits and bit(control) != 0
|
||||
if (!emit) { channel.observe(x, y, contact); continue }
|
||||
val target = channel.update(x, y, contact) ?: continue
|
||||
val targetOffset = offset(target)
|
||||
if (targetOffset != 0) {
|
||||
val value = shortValue(x)
|
||||
if (target.dimensions == 2) {
|
||||
val second = shortValue(y * if (target.isTrackpad) 1f else -1f)
|
||||
val oldX = short(buffer, targetOffset).toLong()
|
||||
val oldY = short(buffer, targetOffset + 2).toLong()
|
||||
if (value.toLong() * value + second.toLong() * second >= oldX * oldX + oldY * oldY) {
|
||||
putShort(buffer, targetOffset, value)
|
||||
putShort(buffer, targetOffset + 2, second)
|
||||
}
|
||||
val touch = if (control.isTrackpad) contact else bits and bit(control) != 0
|
||||
if (touch) putBits(buffer, bits(buffer) or bit(target))
|
||||
} else {
|
||||
if (value >= short(buffer, targetOffset)) putShort(buffer, targetOffset, value)
|
||||
if (bits and bit(control) != 0) putBits(buffer, bits(buffer) or bit(target))
|
||||
}
|
||||
} else if (x > 0.5f) putBits(buffer, bits(buffer) or bit(target))
|
||||
if (target == InputControl.LEFT_CLICK || target == InputControl.RIGHT_CLICK) {
|
||||
val pressureOffset = if (target == InputControl.LEFT_CLICK) 21 else 27
|
||||
val from = if (control == InputControl.LEFT_CLICK) 21 else if (control == InputControl.RIGHT_CLICK) 27 else 0
|
||||
if (from != 0) putShort(buffer, pressureOffset, short(data, from))
|
||||
else if (x > 0.5f) putShort(buffer, pressureOffset, 32767)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() = synchronized(router) { channels.forEach { it.release() }; last = null }
|
||||
fun close() = synchronized(router) { release(); channels.forEach { it.close() }; router.removeSnapshot(snapshot) }
|
||||
|
||||
private fun bits(data: ByteArray): Int = (data[1].toInt() and 255) or ((data[2].toInt() and 255) shl 8) or
|
||||
((data[3].toInt() and 255) shl 16) or ((data[4].toInt() and 255) shl 24)
|
||||
private fun putBits(data: ByteArray, value: Int) { for (i in 0..3) data[i + 1] = (value ushr (i * 8)).toByte() }
|
||||
private fun short(data: ByteArray, offset: Int) = ((data[offset].toInt() and 255) or (data[offset + 1].toInt() shl 8)).toShort().toInt()
|
||||
private fun axis(data: ByteArray, offset: Int): Float = short(data, offset).let { it / if (it < 0) 32768f else 32767f }
|
||||
private fun shortValue(value: Float) = (value * if (value < 0f) 32768f else 32767f).roundToInt().coerceIn(-32768, 32767)
|
||||
private fun putShort(data: ByteArray, offset: Int, value: Int) { data[offset] = value.toByte(); data[offset + 1] = (value shr 8).toByte() }
|
||||
|
||||
companion object {
|
||||
private const val CONTROL_BITS = 0x0fffffff
|
||||
private fun bit(c: InputControl): Int = when (c) {
|
||||
InputControl.A -> 0x1; InputControl.B -> 0x2; InputControl.X -> 0x4; InputControl.Y -> 0x8
|
||||
InputControl.QAM -> 0x10; InputControl.R3 -> 0x20; InputControl.START -> 0x40
|
||||
InputControl.R4 -> 0x80; InputControl.R5 -> 0x100; InputControl.RB -> 0x200
|
||||
InputControl.DOWN -> 0x400; InputControl.RIGHT -> 0x800; InputControl.LEFT -> 0x1000; InputControl.UP -> 0x2000
|
||||
InputControl.SELECT -> 0x4000; InputControl.L3 -> 0x8000; InputControl.GUIDE -> 0x10000
|
||||
InputControl.L4 -> 0x20000; InputControl.L5 -> 0x40000; InputControl.LB -> 0x80000
|
||||
InputControl.RS -> 0x100000; InputControl.RIGHT_PAD -> 0x200000; InputControl.RIGHT_CLICK -> 0x400000
|
||||
InputControl.RT -> 0x800000; InputControl.LS -> 0x1000000; InputControl.LEFT_PAD -> 0x2000000
|
||||
InputControl.LEFT_CLICK -> 0x4000000; InputControl.LT -> 0x8000000
|
||||
}
|
||||
private fun offset(c: InputControl): Int = when (c) {
|
||||
InputControl.LT -> 5; InputControl.RT -> 7; InputControl.LS -> 9; InputControl.RS -> 13
|
||||
InputControl.LEFT_PAD -> 17; InputControl.RIGHT_PAD -> 23
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ class ControllerActions(
|
||||
val onSteamButton: (Boolean) -> Unit,
|
||||
val onQamButton: (Boolean) -> Unit,
|
||||
val onKeyboardButton: (Boolean) -> Unit,
|
||||
val onSelectSteam: (Boolean) -> Unit,
|
||||
val onJoyConVr: (Boolean) -> Unit,
|
||||
val onEditLayout: () -> Unit,
|
||||
val onResetLayout: () -> Unit,
|
||||
@@ -72,7 +71,6 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
|
||||
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
|
||||
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
|
||||
ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam)
|
||||
ToggleRow(host, "controller-joycon-vr", stringResource(R.string.ctrl_joycon_vr), stringResource(R.string.ctrl_joycon_vr_hint), c.joyConVr, onChange = a.onJoyConVr)
|
||||
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@@ -80,27 +78,6 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
SecondaryButton(stringResource(R.string.ctrl_reset), enabled = c.customLayout) { a.onResetLayout() }
|
||||
}
|
||||
}
|
||||
val remapped = c.mapping.count { (id, target) -> id != target }
|
||||
ActionRow(stringResource(R.string.ctrl_mapping), if (remapped == 0) stringResource(R.string.ctrl_mapping_default) else stringResource(R.string.ctrl_mapping_count, remapped, c.mapping.size), stringResource(R.string.ctrl_configure), a.onMapping)
|
||||
ActionRow(stringResource(R.string.input_settings), stringResource(R.string.input_settings_hint), stringResource(R.string.ctrl_configure), a.onMapping)
|
||||
ActionRow(stringResource(R.string.ctrl_reset_all), stringResource(R.string.ctrl_reset_all_hint), stringResource(R.string.ctrl_reset), a.onResetAll)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ControllerMappingPage(mapping: Map<String, String>, onPick: (String, String) -> Unit, onReset: () -> Unit, onBack: () -> Unit) {
|
||||
val host = rememberMenuHost()
|
||||
SettingsPage(
|
||||
host, title = stringResource(R.string.ctrl_mapping), eyebrow = stringResource(R.string.setup_controller),
|
||||
lede = stringResource(R.string.ctrl_mapping_lede),
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) {
|
||||
val targets = ControllerPrefs.targets.map { (id, label) -> id to stringResource(label) }
|
||||
for ((id, name) in ControllerPrefs.mappable) {
|
||||
ChoiceRow(host, "map-$id", stringResource(name), null, targets, mapping[id] ?: id) { onPick(id, it) }
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.ctrl_defaults)) {
|
||||
ActionRow(stringResource(R.string.ctrl_reset_mapping), stringResource(R.string.ctrl_mapping_default), stringResource(R.string.ctrl_reset), onReset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.DeckControls
|
||||
import com.droiddeck.launcher.input.InputRole
|
||||
import com.droiddeck.launcher.input.InputControl
|
||||
import com.droiddeck.launcher.input.InputSurfaceElement
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
@@ -37,9 +40,12 @@ class DeckControlsPanel(
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
private val pad: PadBridge? = null,
|
||||
private val customLayout: List<InputSurfaceElement>? = null,
|
||||
private val inputRole: InputRole = InputRole.SECONDARY,
|
||||
) : LinearLayout(context) {
|
||||
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 deckSource = DeckControls.source(inputRole)
|
||||
private val content = FrameLayout(context)
|
||||
private val tabViews = HashMap<Tab, TextView>()
|
||||
private val topBar: View by lazy { topBar() }
|
||||
@@ -49,11 +55,59 @@ class DeckControlsPanel(
|
||||
setBackgroundColor(Color.BLACK)
|
||||
addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
|
||||
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
select(if (pad != null) selectedWithPad else selected)
|
||||
if (customLayout != null && pad != null) renderCustom(pad, customLayout)
|
||||
else select(if (pad != null) selectedWithPad else selected)
|
||||
}
|
||||
|
||||
private fun renderCustom(pad: PadBridge, layout: List<InputSurfaceElement>) {
|
||||
topBar.visibility = View.GONE
|
||||
val canvas = object : ViewGroup(context) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val w = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val h = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(w, h)
|
||||
for (i in layout.indices) getChildAt(i).measure(MeasureSpec.makeMeasureSpec((w * layout[i].width).toInt(), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec((h * layout[i].height).toInt(), MeasureSpec.EXACTLY))
|
||||
}
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
for (i in layout.indices) {
|
||||
val e = layout[i]
|
||||
val child = getChildAt(i)
|
||||
val x = (width * e.x).toInt(); val y = (height * e.y).toInt()
|
||||
child.layout(x, y, x + child.measuredWidth, y + child.measuredHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (element in layout) {
|
||||
val c = element.control
|
||||
canvas.addView(when (c) {
|
||||
InputControl.LS, InputControl.RS -> StickView(context, pad, c == InputControl.RS)
|
||||
InputControl.LEFT_PAD, InputControl.RIGHT_PAD -> TrackpadView(context, c == InputControl.RIGHT_PAD)
|
||||
InputControl.LEFT_CLICK, InputControl.RIGHT_CLICK -> ClickBar(context, c == InputControl.RIGHT_CLICK)
|
||||
InputControl.L4, InputControl.R4, InputControl.L5, InputControl.R5 -> GripButton(context, c.label, null, 1 shl (c.ordinal - InputControl.L4.ordinal))
|
||||
else -> PadButton(context, pad, c.label) { state, down ->
|
||||
when (c) {
|
||||
InputControl.LT -> state.leftTrigger = if (down) 1f else 0f
|
||||
InputControl.RT -> state.rightTrigger = if (down) 1f else 0f
|
||||
InputControl.UP -> state.up = down
|
||||
InputControl.RIGHT -> state.right = down
|
||||
InputControl.DOWN -> state.down = down
|
||||
InputControl.LEFT -> state.left = down
|
||||
else -> state.press(when (c) {
|
||||
InputControl.GUIDE -> PadState.GUIDE; InputControl.QAM -> PadState.QAM
|
||||
else -> c.ordinal
|
||||
}, down)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
content.addView(canvas, FrameLayout.LayoutParams(-1, -1))
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
DeckControls.releaseAll()
|
||||
deckSource.release()
|
||||
pad?.releaseTouch(this)
|
||||
deckSource.close()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
@@ -96,7 +150,8 @@ class DeckControlsPanel(
|
||||
|
||||
private fun select(tab: Tab) {
|
||||
if (pad != null) selectedWithPad = tab else selected = tab
|
||||
DeckControls.releaseAll()
|
||||
deckSource.release()
|
||||
pad?.releaseTouch(this)
|
||||
for ((each, view) in tabViews) {
|
||||
val on = each == tab
|
||||
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
|
||||
@@ -332,13 +387,13 @@ class DeckControlsPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private class GripButton(context: Context, label: String, caption: String?, bit: Int) :
|
||||
HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) })
|
||||
private inner class GripButton(context: Context, label: String, caption: String?, bit: Int) :
|
||||
HoldButton(context, label, caption, { deckSource.setGrip(bit, it) })
|
||||
|
||||
private class ClickBar(context: Context, right: Boolean) :
|
||||
HoldButton(context, context.getString(R.string.deck_click), if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad), { DeckControls.setClick(right, it) })
|
||||
private inner class ClickBar(context: Context, right: Boolean) :
|
||||
HoldButton(context, context.getString(R.string.deck_click), if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad), { deckSource.setClick(right, it) })
|
||||
|
||||
private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) {
|
||||
private inner 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
|
||||
@@ -384,7 +439,7 @@ class DeckControlsPanel(
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
click(false)
|
||||
touching = false
|
||||
DeckControls.setPad(right, touching = false)
|
||||
deckSource.setPad(right, touching = false)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
@@ -395,7 +450,7 @@ class DeckControlsPanel(
|
||||
fingerX = x.coerceIn(0f, width.toFloat())
|
||||
fingerY = y.coerceIn(0f, height.toFloat())
|
||||
touching = true
|
||||
DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
|
||||
deckSource.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@@ -403,24 +458,25 @@ class DeckControlsPanel(
|
||||
if (clicked == down) return
|
||||
clicked = down
|
||||
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
DeckControls.setClick(right, down)
|
||||
deckSource.setClick(right, down)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private class PadButton(
|
||||
private inner 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) } }) {
|
||||
) : HoldButton(context, label, null, { down -> pad.applyTouch(this@DeckControlsPanel, inputRole) { write(it, down) } }) {
|
||||
override fun onDetachedFromWindow() {
|
||||
pad.applyTouch { write(it, false) }
|
||||
super.onDetachedFromWindow()
|
||||
pad.applyTouch(this@DeckControlsPanel, inputRole) { write(it, false) }
|
||||
deckSource.close()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) {
|
||||
private inner 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
|
||||
@@ -478,7 +534,7 @@ class DeckControlsPanel(
|
||||
private fun move(dx: Float, dy: Float, r: Float) {
|
||||
knobX = dx
|
||||
knobY = dy
|
||||
pad.applyTouch { s ->
|
||||
pad.applyTouch(this@DeckControlsPanel, inputRole) { s ->
|
||||
if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r }
|
||||
}
|
||||
invalidate()
|
||||
@@ -488,17 +544,18 @@ class DeckControlsPanel(
|
||||
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) }
|
||||
pad.applyTouch(this@DeckControlsPanel, inputRole) { 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()
|
||||
deckSource.close()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private inner class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private var held = 0
|
||||
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
@@ -544,7 +601,7 @@ class DeckControlsPanel(
|
||||
if (bits == held) return
|
||||
if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
held = bits
|
||||
pad.applyTouch { s ->
|
||||
pad.applyTouch(this@DeckControlsPanel, inputRole) { 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()
|
||||
@@ -552,11 +609,12 @@ class DeckControlsPanel(
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(0)
|
||||
super.onDetachedFromWindow()
|
||||
deckSource.close()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private inner 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)
|
||||
@@ -607,13 +665,14 @@ class DeckControlsPanel(
|
||||
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) }
|
||||
pad.applyTouch(this@DeckControlsPanel, inputRole) { s -> for (b in before + now) s.press(b, b in now) }
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(emptySet())
|
||||
super.onDetachedFromWindow()
|
||||
deckSource.close()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.*
|
||||
|
||||
@Composable
|
||||
fun InputSettingsDialog(onBack: () -> Unit, onChanged: () -> Unit) {
|
||||
Dialog(onDismissRequest = onBack, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Surface(Modifier.fillMaxWidth(0.95f).fillMaxHeight(0.95f)) {
|
||||
InputSettingsPage(onBack, onChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InputSettingsPage(onBack: () -> Unit, onChanged: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val host = rememberMenuHost()
|
||||
var measuring by remember { mutableStateOf(InputTiming.enabled) }
|
||||
var timing by remember { mutableStateOf(InputTiming.report()) }
|
||||
var id by remember { mutableStateOf(InputProfile.GAME) }
|
||||
var profile by remember { mutableStateOf(InputProfiles.read(context, id)) }
|
||||
var selected by remember { mutableStateOf(InputProfiles.selection(context)) }
|
||||
var role by remember { mutableStateOf(InputRole.PHYSICAL) }
|
||||
var edit by remember { mutableStateOf<InputControl?>(null) }
|
||||
var capture by remember { mutableStateOf(false) }
|
||||
var editKey by remember { mutableStateOf<PhysicalInputKey?>(null) }
|
||||
var surface by remember { mutableStateOf(false) }
|
||||
val names = listOf(InputProfile.GAME to stringResource(R.string.input_profile_game), InputProfile.DESKTOP to stringResource(R.string.input_profile_desktop))
|
||||
SettingsPage(host, title = stringResource(R.string.input_settings), eyebrow = stringResource(R.string.setup_controller),
|
||||
lede = stringResource(R.string.input_settings_hint), onBack = onBack) {
|
||||
SettingsGroup(stringResource(R.string.input_profiles)) {
|
||||
ChoiceRow(host, "input-active", stringResource(R.string.input_active_profile), stringResource(R.string.input_auto_hint),
|
||||
listOf(InputProfiles.AUTO to stringResource(R.string.common_auto)) + names, selected) {
|
||||
InputProfiles.select(context, it); selected = it; onChanged()
|
||||
}
|
||||
ChoiceRow(host, "input-edit-profile", stringResource(R.string.input_edit_profile), null, names, id) {
|
||||
id = it; profile = InputProfiles.read(context, it)
|
||||
}
|
||||
ChoiceRow(host, "input-source", stringResource(R.string.input_source), null,
|
||||
listOf(InputRole.PHYSICAL.name to stringResource(R.string.input_physical), InputRole.PRIMARY.name to stringResource(R.string.input_primary),
|
||||
InputRole.SECONDARY.name to stringResource(R.string.input_secondary)), role.name) { role = InputRole.valueOf(it) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.input_bindings)) {
|
||||
for (control in InputControl.entries) {
|
||||
ActionRow(control.label, actionLabel(profile.action(role, control)), stringResource(R.string.ctrl_edit), { edit = control }, modifier = Modifier.testTag("input-binding-${control.id}"))
|
||||
}
|
||||
}
|
||||
if (role == InputRole.PHYSICAL) SettingsGroup(stringResource(R.string.input_extra_keys)) {
|
||||
for (binding in profile.keys) {
|
||||
ActionRow(binding.key.label, if (binding.action is InputAction.Native) stringResource(R.string.input_normal_binding) else actionLabel(binding.action), stringResource(R.string.ctrl_edit), { editKey = binding.key })
|
||||
TextButton(onClick = {
|
||||
profile = profile.copy(keys = profile.keys - binding); InputProfiles.save(context, profile); onChanged()
|
||||
}) { Text(stringResource(R.string.input_remove_binding)) }
|
||||
}
|
||||
ActionRow(stringResource(R.string.input_capture), stringResource(R.string.input_capture_hint), stringResource(R.string.ctrl_edit), { capture = true })
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.input_latency)) {
|
||||
Text(stringResource(R.string.input_latency_hint))
|
||||
Row {
|
||||
Switch(measuring, onCheckedChange = { measuring = it; InputTiming.enabled = it; if (it) InputTiming.reset() })
|
||||
Text(stringResource(R.string.input_measure))
|
||||
}
|
||||
Text(timing)
|
||||
TextButton(onClick = { timing = InputTiming.report(); android.util.Log.i("InputTiming", timing) }) { Text(stringResource(R.string.input_read_timing)) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.input_surface)) {
|
||||
ActionRow(stringResource(R.string.input_surface_edit), stringResource(R.string.input_surface_hint), stringResource(R.string.ctrl_edit), { surface = true }, modifier = Modifier.testTag("input-surface-edit"))
|
||||
ActionRow(stringResource(R.string.ctrl_reset_mapping), null, stringResource(R.string.ctrl_reset), {
|
||||
InputProfiles.reset(context, id); profile = InputProfiles.read(context, id); onChanged()
|
||||
})
|
||||
}
|
||||
}
|
||||
edit?.let { control ->
|
||||
InputActionDialog(control, profile.action(role, control), onDismiss = { edit = null }) { action ->
|
||||
profile = profile.withBinding(role, control, action)
|
||||
InputProfiles.save(context, profile)
|
||||
edit = null
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
if (capture) InputCaptureDialog(onDismiss = { capture = false }) { capture = false; editKey = it }
|
||||
editKey?.let { key -> InputActionDialog(InputControl.A, profile.keys.firstOrNull { it.key == key }?.action ?: InputAction.Disabled,
|
||||
onDismiss = { editKey = null }, nativeTargets = false, title = key.label, onPick = { action ->
|
||||
profile = profile.copy(keys = profile.keys.filterNot { it.key == key } + PhysicalInputBinding(key, action))
|
||||
InputProfiles.save(context, profile); editKey = null; onChanged()
|
||||
}) }
|
||||
if (surface) InputSurfaceDialog(id, onDismiss = { surface = false }, onChanged)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun actionLabel(action: InputAction): String {
|
||||
val context = LocalContext.current
|
||||
return when (action) {
|
||||
is InputAction.Native -> action.target?.let { stringResource(R.string.input_native_target, it.label) } ?: stringResource(R.string.input_native)
|
||||
InputAction.Disabled -> stringResource(R.string.input_disabled)
|
||||
is InputAction.Keys -> action.codes.joinToString(" + ") { pcKeyLabel(context, it) }
|
||||
is InputAction.MouseButton -> stringResource(when (action.code) { 272 -> R.string.input_mouse_left; 273 -> R.string.input_mouse_right; else -> R.string.input_mouse_middle })
|
||||
is InputAction.Command -> commandLabel(action.command)
|
||||
is InputAction.Directions -> listOf(action.up, action.right, action.down, action.left).joinToString(" / ") { pcKeyLabel(context, it) }
|
||||
is InputAction.Pointer -> stringResource(R.string.input_pointer)
|
||||
is InputAction.Scroll -> stringResource(R.string.input_scroll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun commandLabel(command: InputCommand) = stringResource(when (command) {
|
||||
InputCommand.MENU -> R.string.input_command_menu
|
||||
InputCommand.KEYBOARD -> R.string.input_command_keyboard
|
||||
InputCommand.STEAM -> R.string.input_command_steam
|
||||
InputCommand.QUICK_ACCESS -> R.string.input_command_qam
|
||||
})
|
||||
|
||||
@Composable
|
||||
private fun InputActionDialog(control: InputControl, current: InputAction, onDismiss: () -> Unit, nativeTargets: Boolean = true, title: String = control.label, onPick: (InputAction) -> Unit) {
|
||||
var keys by remember { mutableStateOf(false) }
|
||||
var directions by remember { mutableStateOf(false) }
|
||||
var native by remember { mutableStateOf(false) }
|
||||
var speed by remember { mutableFloatStateOf(when (current) { is InputAction.Pointer -> current.speed; else -> 900f }) }
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(title) }, confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
TextButton(onClick = { onPick(InputAction.Native()) }) { Text(stringResource(if (nativeTargets) R.string.input_native else R.string.input_normal_binding)) }
|
||||
if (nativeTargets) TextButton(onClick = { native = true }) { Text(stringResource(R.string.input_remap_native)) }
|
||||
TextButton(onClick = { onPick(InputAction.Disabled) }) { Text(stringResource(R.string.input_disabled)) }
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = { keys = true }) { Text(stringResource(R.string.input_keys)) }
|
||||
if (control.dimensions == 2) {
|
||||
Text(stringResource(R.string.input_pointer_speed, speed.toInt()))
|
||||
Slider(value = speed, onValueChange = { speed = it }, valueRange = 100f..3000f)
|
||||
TextButton(onClick = { onPick(InputAction.Pointer(speed)) }) { Text(stringResource(R.string.input_pointer)) }
|
||||
TextButton(onClick = { onPick(InputAction.Scroll()) }) { Text(stringResource(R.string.input_scroll)) }
|
||||
TextButton(onClick = { directions = true }) { Text(stringResource(R.string.input_direction_keys)) }
|
||||
}
|
||||
for (code in 272..274) TextButton(onClick = { onPick(InputAction.MouseButton(code)) }) { Text(actionLabel(InputAction.MouseButton(code))) }
|
||||
for (command in InputCommand.entries) TextButton(onClick = { onPick(InputAction.Command(command)) }) { Text(commandLabel(command)) }
|
||||
}
|
||||
})
|
||||
if (keys) InputKeysDialog((current as? InputAction.Keys)?.codes.orEmpty(), onDismiss = { keys = false }, onPick = { onPick(InputAction.Keys(it)) })
|
||||
if (native) AlertDialog(onDismissRequest = { native = false }, title = { Text(stringResource(R.string.input_remap_native)) }, confirmButton = {},
|
||||
text = { Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
for (target in InputControl.entries) if (InputAction.Native(target).validFor(control)) {
|
||||
TextButton(onClick = { onPick(InputAction.Native(target)) }) { Text(target.label) }
|
||||
}
|
||||
} })
|
||||
if (directions) InputDirectionsDialog((current as? InputAction.Directions) ?: InputAction.Directions(103, 106, 108, 105),
|
||||
onDismiss = { directions = false }, onPick = onPick)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun InputKeysDialog(initial: List<Int>, onDismiss: () -> Unit, onPick: (List<Int>) -> Unit, single: Boolean = false) {
|
||||
val context = LocalContext.current
|
||||
var codes by remember { mutableStateOf(initial) }
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_keys)) },
|
||||
confirmButton = { TextButton(enabled = codes.isNotEmpty(), onClick = {
|
||||
onPick(codes.sortedBy { if (it in PC_MODIFIERS) 0 else 1 })
|
||||
}) { Text(stringResource(R.string.gesture_save_changes)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } },
|
||||
text = { Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(codes.joinToString(" + ") { pcKeyLabel(context, it) })
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (key in PC_KEYS.values) FilterChip(selected = key.code in codes, onClick = {
|
||||
codes = if (single) listOf(key.code) else if (key.code in codes) codes - key.code else (codes + key.code).take(8)
|
||||
}, label = { Text(pcKeyLabel(context, key.code)) })
|
||||
}
|
||||
} })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputDirectionsDialog(initial: InputAction.Directions, onDismiss: () -> Unit, onPick: (InputAction) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var codes by remember { mutableStateOf(listOf(initial.up, initial.right, initial.down, initial.left)) }
|
||||
var editing by remember { mutableStateOf<Int?>(null) }
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_direction_keys)) },
|
||||
confirmButton = { TextButton(onClick = { onPick(InputAction.Directions(codes[0], codes[1], codes[2], codes[3])) }) { Text(stringResource(R.string.gesture_save_changes)) } },
|
||||
text = { Column {
|
||||
listOf("↑", "→", "↓", "←").forEachIndexed { i, label ->
|
||||
TextButton(onClick = { editing = i }) { Text("$label ${pcKeyLabel(context, codes[i])}") }
|
||||
}
|
||||
TextButton(onClick = { codes = listOf(17, 32, 31, 30) }) { Text("W / D / S / A") }
|
||||
} })
|
||||
editing?.let { i -> InputKeysDialog(listOf(codes[i]), onDismiss = { editing = null }, single = true, onPick = { value ->
|
||||
codes = codes.toMutableList().also { it[i] = value.first() }; editing = null
|
||||
}) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputCaptureDialog(onDismiss: () -> Unit, onPick: (PhysicalInputKey) -> Unit) {
|
||||
val focus = remember { FocusRequester() }
|
||||
var captured by remember { mutableStateOf<PhysicalInputKey?>(null) }
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_capture)) },
|
||||
modifier = Modifier.onPreviewKeyEvent {
|
||||
val event = it.nativeKeyEvent
|
||||
val key = PhysicalInputKey.capture(event)
|
||||
if (key != null) {
|
||||
if (event.action == android.view.KeyEvent.ACTION_DOWN && event.repeatCount == 0) captured = key
|
||||
true
|
||||
} else false
|
||||
}.focusRequester(focus).focusable(),
|
||||
text = { Text(captured?.label ?: stringResource(R.string.input_capture_hint)) },
|
||||
confirmButton = { TextButton(enabled = captured != null, onClick = { captured?.let(onPick) }) { Text(stringResource(R.string.ctrl_edit)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } })
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.input.*
|
||||
|
||||
@Composable
|
||||
fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var layout by remember { mutableStateOf(InputSurfaceLayout.read(context, profile)) }
|
||||
var selected by remember { mutableStateOf(InputControl.A) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
val element = layout.firstOrNull { it.control == selected }
|
||||
fun change(next: InputSurfaceElement) { layout = layout.map { if (it.control == next.control) next.bounded() else it } }
|
||||
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.input_surface_edit)) },
|
||||
confirmButton = { TextButton(onClick = { InputSurfaceLayout.save(context, profile, layout); onChanged(); onDismiss() }) { Text(stringResource(R.string.gesture_save_changes)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } },
|
||||
text = { Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(stringResource(R.string.input_surface_hint))
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(180.dp).background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
for (e in layout) Surface(Modifier.offset(maxWidth * e.x, maxHeight * e.y).size(maxWidth * e.width, maxHeight * e.height)
|
||||
.padding(2.dp).clickable { selected = e.control },
|
||||
color = if (e.control == selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer) {
|
||||
Text(e.control.label, Modifier.padding(4.dp), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
if (element != null) {
|
||||
Text(element.control.label)
|
||||
Text(stringResource(R.string.input_position_x)); Slider(element.x, { change(element.copy(x = it)) }, valueRange = 0f..0.9f)
|
||||
Text(stringResource(R.string.input_position_y)); Slider(element.y, { change(element.copy(y = it)) }, valueRange = 0f..0.9f)
|
||||
Text(stringResource(R.string.input_width)); Slider(element.width, { change(element.copy(width = it)) }, valueRange = 0.1f..1f)
|
||||
Text(stringResource(R.string.input_height)); Slider(element.height, { change(element.copy(height = it)) }, valueRange = 0.1f..1f)
|
||||
TextButton(onClick = { layout = layout.filter { it.control != selected } }) { Text(stringResource(R.string.input_remove_element)) }
|
||||
}
|
||||
TextButton(onClick = { adding = true }) { Text(stringResource(R.string.input_add_element)) }
|
||||
TextButton(onClick = { layout = InputSurfaceLayout.defaults() }) { Text(stringResource(R.string.ctrl_reset)) }
|
||||
} })
|
||||
if (adding) AlertDialog(onDismissRequest = { adding = false }, title = { Text(stringResource(R.string.input_add_element)) }, confirmButton = {},
|
||||
text = { Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
for (control in InputControl.entries) if (layout.none { it.control == control }) {
|
||||
TextButton(onClick = {
|
||||
layout = layout + InputSurfaceElement(control, 0.1f, 0.1f, if (control.dimensions == 2) 0.4f else 0.2f, 0.3f)
|
||||
selected = control; adding = false
|
||||
}) { Text(control.label) }
|
||||
}
|
||||
} })
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.input.PcKey
|
||||
import com.droiddeck.launcher.input.PC_KEY_ROWS
|
||||
import com.droiddeck.launcher.input.PC_MODIFIERS
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -54,46 +57,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private data class PcKey(val label: String, val code: Int, val weight: Float = 1f, val shifted: String? = null, @StringRes val labelRes: Int = 0)
|
||||
|
||||
private fun k(label: String, code: Int, weight: Float = 1f, shifted: String? = null) = PcKey(label, code, weight, shifted)
|
||||
|
||||
private fun k(@StringRes labelRes: Int, code: Int, weight: Float = 1f) = PcKey("", code, weight, null, labelRes)
|
||||
|
||||
private val ROWS: List<List<PcKey>> = listOf(
|
||||
listOf(
|
||||
k(R.string.pckb_key_esc, 1), k("F1", 59), k("F2", 60), k("F3", 61), k("F4", 62), k("F5", 63), k("F6", 64),
|
||||
k("F7", 65), k("F8", 66), k("F9", 67), k("F10", 68), k("F11", 87), k("F12", 88),
|
||||
k(R.string.pckb_key_ins, 110), k(R.string.pckb_key_del, 111), k(R.string.pckb_key_home, 102), k(R.string.pckb_key_end, 107), k(R.string.pckb_key_pgup, 104), k(R.string.pckb_key_pgdn, 109),
|
||||
),
|
||||
listOf(
|
||||
k("`", 41, shifted = "~"), k("1", 2, shifted = "!"), k("2", 3, shifted = "@"), k("3", 4, shifted = "#"),
|
||||
k("4", 5, shifted = "$"), k("5", 6, shifted = "%"), k("6", 7, shifted = "^"), k("7", 8, shifted = "&"),
|
||||
k("8", 9, shifted = "*"), k("9", 10, shifted = "("), k("0", 11, shifted = ")"), k("-", 12, shifted = "_"),
|
||||
k("=", 13, shifted = "+"), k(R.string.pckb_key_backspace, 14, 2f),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_tab, 15, 1.5f), k("q", 16), k("w", 17), k("e", 18), k("r", 19), k("t", 20), k("y", 21),
|
||||
k("u", 22), k("i", 23), k("o", 24), k("p", 25), k("[", 26, shifted = "{"), k("]", 27, shifted = "}"),
|
||||
k("\\", 43, 1.5f, shifted = "|"),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_caps, 58, 1.75f), k("a", 30), k("s", 31), k("d", 32), k("f", 33), k("g", 34), k("h", 35),
|
||||
k("j", 36), k("k", 37), k("l", 38), k(";", 39, shifted = ":"), k("'", 40, shifted = "\""),
|
||||
k(R.string.pckb_key_enter, 28, 2.25f),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_shift, 42, 2.25f), k("z", 44), k("x", 45), k("c", 46), k("v", 47), k("b", 48), k("n", 49),
|
||||
k("m", 50), k(",", 51, shifted = "<"), k(".", 52, shifted = ">"), k("/", 53, shifted = "?"),
|
||||
k(R.string.pckb_key_shift, 54, 1.75f), k("↑", 103),
|
||||
),
|
||||
listOf(
|
||||
k(R.string.pckb_key_ctrl, 29, 1.5f), k(R.string.pckb_key_super, 125, 1.25f), k(R.string.pckb_key_alt, 56, 1.25f), k(R.string.pckb_key_space, 57, 6.25f),
|
||||
k(R.string.pckb_key_altgr, 100, 1.25f), k(R.string.pckb_key_ctrl, 97, 1.25f), k("←", 105), k("↓", 108), k("→", 106),
|
||||
),
|
||||
)
|
||||
|
||||
private val MODIFIERS = setOf(42, 54, 29, 97, 56, 100, 125)
|
||||
private const val MOD_OFF = 0
|
||||
private const val MOD_ONCE = 1
|
||||
private const val MOD_LOCKED = 2
|
||||
@@ -115,7 +78,7 @@ fun PcKeyboard(
|
||||
for ((code, state) in mods.toMap()) if (state == MOD_ONCE) { sendKey(code, false); mods[code] = MOD_OFF }
|
||||
}
|
||||
fun press(key: PcKey) {
|
||||
if (key.code in MODIFIERS) {
|
||||
if (key.code in PC_MODIFIERS) {
|
||||
when (mods[key.code] ?: MOD_OFF) {
|
||||
MOD_OFF -> { sendKey(key.code, true); mods[key.code] = MOD_ONCE }
|
||||
MOD_ONCE -> mods[key.code] = MOD_LOCKED
|
||||
@@ -126,7 +89,7 @@ fun PcKeyboard(
|
||||
sendKey(key.code, true)
|
||||
}
|
||||
fun release(key: PcKey) {
|
||||
if (key.code in MODIFIERS) return
|
||||
if (key.code in PC_MODIFIERS) return
|
||||
sendKey(key.code, false)
|
||||
if (key.code == 58) capsOn = !capsOn
|
||||
releaseOnceModifiers()
|
||||
@@ -153,7 +116,7 @@ fun PcKeyboard(
|
||||
Spacer(Modifier.width(8.dp))
|
||||
HeaderButton(stringResource(R.string.pckb_hide), onClose)
|
||||
}
|
||||
ROWS.forEachIndexed { r, row ->
|
||||
PC_KEY_ROWS.forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.fillMaxWidth().height(rowHeight)) {
|
||||
row.forEachIndexed { i, key ->
|
||||
val state = mods[key.code] ?: MOD_OFF
|
||||
|
||||
@@ -27,6 +27,8 @@ import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.input.TouchpadGestures
|
||||
import com.droiddeck.launcher.input.InputProfiles
|
||||
import com.droiddeck.launcher.input.InputSurfaceLayout
|
||||
import com.droiddeck.launcher.SessionActivity
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
@@ -92,12 +94,27 @@ class SecondScreenPresentation(
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (currentMode == SecondScreenMode.KEYBOARD_TRACKPAD) {
|
||||
if (currentMode != SecondScreenMode.TERMINAL) {
|
||||
if (sessionActivity.dispatchKeyEvent(event)) return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (currentMode != SecondScreenMode.TERMINAL && PadBridge.isControllerMotionEvent(event)) {
|
||||
return sessionActivity.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
fun refreshControls() { if (created) renderMode() }
|
||||
|
||||
fun showKeyboard(): Boolean {
|
||||
val host = keyboardHost ?: return false
|
||||
host.show()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
@@ -119,6 +136,12 @@ class SecondScreenPresentation(
|
||||
SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad()
|
||||
SecondScreenMode.TERMINAL -> renderTerminal()
|
||||
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad), weightParams())
|
||||
SecondScreenMode.CUSTOM_CONTROLS -> {
|
||||
val profile = InputProfiles.active(context, SessionState.mode == SessionService.MODE_DESKTOP)
|
||||
root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad,
|
||||
InputSurfaceLayout.read(context, profile.id)), weightParams())
|
||||
keyboardHost = KeyboardHost(context) { sessionActivity.dispatchKeyEvent(it) }.also { root.addView(it, ViewGroup.LayoutParams(1, 1)) }
|
||||
}
|
||||
SecondScreenMode.NONE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ class DrawerActions(
|
||||
val onLowLatencyController: (Boolean) -> Unit = {},
|
||||
val onLowLatencyTouch: (Boolean) -> Unit = {},
|
||||
val onTouch: (String) -> Unit,
|
||||
val onInputSettings: () -> Unit = {},
|
||||
val onGestureSettings: () -> Unit = {},
|
||||
val onFexPreset: (String) -> Unit,
|
||||
val onUpscaler: (Int) -> Unit = {},
|
||||
@@ -546,6 +547,8 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
|
||||
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
|
||||
ActionRow(stringResource(R.string.input_settings), null, stringResource(R.string.ctrl_edit), a.onInputSettings,
|
||||
modifier = focus.track(page, "input-settings"))
|
||||
ActionRow(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_settings_hint),
|
||||
stringResource(R.string.ctrl_edit), a.onGestureSettings,
|
||||
modifier = focus.track(page, "gesture-settings"))
|
||||
@@ -1051,7 +1054,7 @@ private fun SteamVrRow(a: DrawerActions) {
|
||||
private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
|
||||
val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.CUSTOM_CONTROLS, SecondScreenMode.TERMINAL) +
|
||||
if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()
|
||||
SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) {
|
||||
Column(Modifier.fillMaxWidth().selectableGroup()) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<resources>
|
||||
<string name="input_settings">Input profiles</string>
|
||||
<string name="input_settings_hint">Choose what each control sends. Native controls keep their gamepad or Steam Input behavior; local actions send keys, mouse input or DroidDeck commands.</string>
|
||||
<string name="input_profiles">Profiles</string>
|
||||
<string name="input_profile_game">Game</string>
|
||||
<string name="input_profile_desktop">Desktop</string>
|
||||
<string name="input_active_profile">Active profile</string>
|
||||
<string name="input_auto_hint">Auto uses Desktop in desktop sessions and Game in other sessions. Select Game manually when playing a game from the desktop.</string>
|
||||
<string name="input_edit_profile">Edit profile</string>
|
||||
<string name="input_source">Control source</string>
|
||||
<string name="input_physical">Physical controller</string>
|
||||
<string name="input_primary">Main screen</string>
|
||||
<string name="input_secondary">Second screen</string>
|
||||
<string name="input_bindings">Bindings</string>
|
||||
<string name="input_native">Native / Steam Input</string>
|
||||
<string name="input_native_target">Native: %1$s</string>
|
||||
<string name="input_remap_native">Send another controller control</string>
|
||||
<string name="input_disabled">Disabled</string>
|
||||
<string name="input_keys">Key or key combination</string>
|
||||
<string name="input_direction_keys">Directional keys</string>
|
||||
<string name="input_pointer">Move pointer</string>
|
||||
<string name="input_pointer_speed">Pointer speed: %1$d</string>
|
||||
<string name="input_scroll">Scroll vertically</string>
|
||||
<string name="input_mouse_left">Left mouse button</string>
|
||||
<string name="input_mouse_right">Right mouse button</string>
|
||||
<string name="input_mouse_middle">Middle mouse button</string>
|
||||
<string name="input_command_menu">Open DroidDeck menu</string>
|
||||
<string name="input_command_keyboard">Show keyboard</string>
|
||||
<string name="input_command_steam">Open Steam menu</string>
|
||||
<string name="input_command_qam">Open Steam quick access</string>
|
||||
<string name="input_surface">Screen layout</string>
|
||||
<string name="input_surface_edit">Custom second-screen layout</string>
|
||||
<string name="input_surface_hint">Arrange controls here, then select Custom controls in the second-screen panel. Their actions come from this profile’s Second screen bindings. Native trackpads require a Steam Deck controller; Steam Mobile Touch layouts remain configured in Steam.</string>
|
||||
<string name="input_position_x">Horizontal position</string>
|
||||
<string name="input_position_y">Vertical position</string>
|
||||
<string name="input_width">Width</string>
|
||||
<string name="input_height">Height</string>
|
||||
<string name="input_remove_element">Remove control</string>
|
||||
<string name="input_add_element">Add control</string>
|
||||
<string name="input_custom_controls">Custom controls</string>
|
||||
<string name="input_extra_keys">Device-specific buttons</string>
|
||||
<string name="input_capture">Assign a physical button</string>
|
||||
<string name="input_capture_hint">Press a button, including a back button reported as a keyboard key. Volume and Android system keys are reserved. The assignment applies only to this device.</string>
|
||||
<string name="input_remove_binding">Remove assignment</string>
|
||||
<string name="input_latency">Input diagnostics</string>
|
||||
<string name="input_measure">Record input dispatch time</string>
|
||||
<string name="input_read_timing">Read measurements</string>
|
||||
<string name="input_latency_hint">Measures Android dispatch through publication to the existing input transport. This excludes Bluetooth arrival, guest polling, rendering and display latency. Recording is off by default.</string>
|
||||
<string name="input_normal_binding">Use normal input binding</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], manifest = Config.NONE)
|
||||
class InputProfilesTest {
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
@Before fun reset() {
|
||||
context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("controller", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun migrationKeepsLegacyTouchMappingSeparateFromPhysicalControls() {
|
||||
ControllerPrefs.setTarget(context, "a", "b")
|
||||
ControllerPrefs.setTarget(context, "x", "off")
|
||||
ControllerPrefs.setSelectSteam(context, true)
|
||||
val game = InputProfiles.read(context, InputProfile.GAME)
|
||||
assertEquals(InputAction.Native(InputControl.B), game.action(InputRole.PRIMARY, InputControl.A))
|
||||
assertEquals(InputAction.Disabled, game.action(InputRole.PRIMARY, InputControl.X))
|
||||
assertEquals(InputAction.Native(), game.action(InputRole.PHYSICAL, InputControl.A))
|
||||
assertEquals(InputAction.Native(InputControl.GUIDE), game.action(InputRole.PHYSICAL, InputControl.SELECT))
|
||||
InputProfiles.reset(context, InputProfile.GAME)
|
||||
assertEquals(InputAction.Native(), InputProfiles.read(context, InputProfile.GAME).action(InputRole.PRIMARY, InputControl.A))
|
||||
}
|
||||
|
||||
@Test fun bindingsAndDeviceKeysRoundTripWithoutAffectingOtherProfile() {
|
||||
val game = InputProfile.defaults(InputProfile.GAME)
|
||||
.withBinding(InputRole.SECONDARY, InputControl.A, InputAction.Keys(listOf(29, 30)))
|
||||
.copy(keys = listOf(PhysicalInputBinding(PhysicalInputKey("1:2:descriptor", "Thor", 131, 59), InputAction.Command(InputCommand.MENU))))
|
||||
InputProfiles.save(context, game)
|
||||
assertEquals(game, InputProfiles.read(context, InputProfile.GAME))
|
||||
assertEquals(InputAction.Pointer(), InputProfiles.read(context, InputProfile.DESKTOP).action(InputRole.PHYSICAL, InputControl.LS))
|
||||
}
|
||||
|
||||
@Test fun profileSelectionIsExplicitAndNeverDependsOnDisplayPresence() {
|
||||
assertEquals(InputProfile.DESKTOP, InputProfiles.active(context, true).id)
|
||||
assertEquals(InputProfile.GAME, InputProfiles.active(context, false).id)
|
||||
InputProfiles.select(context, InputProfile.GAME)
|
||||
assertEquals(InputProfile.GAME, InputProfiles.active(context, true).id)
|
||||
}
|
||||
|
||||
@Test fun unknownVersionDoesNotExecuteBindings() {
|
||||
context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE).edit()
|
||||
.putString("profile.game", """{"version":999,"bindings":[]} """).commit()
|
||||
assertEquals(InputProfile.defaults(InputProfile.GAME), InputProfiles.read(context, InputProfile.GAME))
|
||||
}
|
||||
|
||||
@Test fun layoutsPersistIndependentOfBindingsAndStayInsideScreen() {
|
||||
InputSurfaceLayout.save(context, InputProfile.GAME, listOf(InputSurfaceElement(InputControl.A, .95f, -.1f, 2f, 2f)))
|
||||
val element = InputSurfaceLayout.read(context, InputProfile.GAME).single()
|
||||
assertEquals(.9f, element.x); assertEquals(0f, element.y)
|
||||
assertTrue(element.x + element.width <= 1f)
|
||||
assertEquals(InputAction.Native(), InputProfiles.read(context, InputProfile.GAME).action(InputRole.SECONDARY, InputControl.A))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class InputRouterTest {
|
||||
private class Output : InputOutput {
|
||||
val events = mutableListOf<String>()
|
||||
override fun key(code: Int, down: Boolean) { events += "key $code $down" }
|
||||
override fun button(code: Int, down: Boolean) { events += "button $code $down" }
|
||||
override fun pointer(dx: Float, dy: Float) { events += "pointer $dx $dy" }
|
||||
override fun scroll(steps: Int) { events += "scroll $steps" }
|
||||
override fun command(command: InputCommand) { events += command.name }
|
||||
override fun requestMotion() { events += "motion" }
|
||||
}
|
||||
private val output = Output()
|
||||
private val router = InputRouter(output)
|
||||
private fun bind(control: InputControl, action: InputAction, role: InputRole = InputRole.PHYSICAL) =
|
||||
InputProfile.defaults(InputProfile.GAME).withBinding(role, control, action)
|
||||
|
||||
@Test fun nativeProfileReturnsOriginalStateWithoutScheduling() {
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val state = PadState().apply { leftX = .7f; press(PadState.A, true) }
|
||||
repeat(1000) { assertSame(state, source.route(state)) }
|
||||
assertTrue(output.events.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun localBindingConsumesOnlyItsOwnNativeControlAndReleasesModifiersLast() {
|
||||
router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(29, 30))))
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val state = PadState().apply { press(PadState.A, true); press(PadState.B, true); leftX = .4f }
|
||||
val routed = source.route(state)
|
||||
assertFalse(routed.isDown(PadState.A)); assertTrue(routed.isDown(PadState.B))
|
||||
assertEquals(.4f, routed.leftX)
|
||||
source.route(state)
|
||||
state.press(PadState.A, false); source.route(state)
|
||||
assertEquals(listOf("key 29 true", "key 30 true", "key 30 false", "key 29 false"), output.events)
|
||||
}
|
||||
|
||||
@Test fun twoSourcesHoldingOneKeyDoNotReleaseEachOther() {
|
||||
val p = bind(InputControl.A, InputAction.Keys(listOf(57)))
|
||||
.withBinding(InputRole.SECONDARY, InputControl.B, InputAction.Keys(listOf(57)))
|
||||
router.setProfile(p)
|
||||
val physical = router.pad(InputRole.PHYSICAL)
|
||||
val touch = router.pad(InputRole.SECONDARY)
|
||||
physical.route(PadState().apply { press(PadState.A, true) })
|
||||
touch.route(PadState().apply { press(PadState.B, true) })
|
||||
touch.close()
|
||||
assertEquals(listOf("key 57 true"), output.events)
|
||||
physical.release()
|
||||
assertEquals(listOf("key 57 true", "key 57 false"), output.events)
|
||||
}
|
||||
|
||||
@Test fun remapCombinesHeldTargetsRatherThanLastButtonWinning() {
|
||||
router.setProfile(bind(InputControl.A, InputAction.Native(InputControl.B)))
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val raw = PadState().apply { press(PadState.A, true) }
|
||||
assertTrue(source.route(raw).isDown(PadState.B))
|
||||
raw.press(PadState.B, true); raw.press(PadState.A, false)
|
||||
assertTrue(source.route(raw).isDown(PadState.B))
|
||||
raw.press(PadState.B, false)
|
||||
assertFalse(source.route(raw).isDown(PadState.B))
|
||||
}
|
||||
|
||||
@Test fun profileChangeBlocksHeldNativeInputUntilNeutral() {
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val raw = PadState().apply { press(PadState.A, true) }
|
||||
source.route(raw)
|
||||
router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(1))))
|
||||
source.route(raw)
|
||||
assertTrue(output.events.isEmpty())
|
||||
raw.press(PadState.A, false); source.route(raw)
|
||||
raw.press(PadState.A, true); source.route(raw)
|
||||
assertEquals(listOf("key 1 true"), output.events)
|
||||
}
|
||||
|
||||
@Test fun changingLocalBindingReleasesOldKeyWithoutPressingNewOne() {
|
||||
router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(1))))
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val raw = PadState().apply { press(PadState.A, true) }
|
||||
source.route(raw)
|
||||
router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(28))))
|
||||
source.route(raw)
|
||||
assertEquals(listOf("key 1 true", "key 1 false"), output.events)
|
||||
}
|
||||
|
||||
@Test fun triggersUseHysteresisAndCommandsFireOnce() {
|
||||
router.setProfile(bind(InputControl.LT, InputAction.Command(InputCommand.MENU)))
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val raw = PadState()
|
||||
for (value in listOf(.6f, .5f, .4f, .6f, .3f, .6f)) { raw.leftTrigger = value; source.route(raw) }
|
||||
assertEquals(listOf("MENU", "MENU"), output.events)
|
||||
}
|
||||
|
||||
@Test fun pointerTicksOnlyWhileMovingAndOrdinaryKeysDoNotWaitForTick() {
|
||||
router.setProfile(InputProfile.defaults(InputProfile.DESKTOP))
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val raw = PadState().apply { leftX = 1f; press(PadState.X, true) }
|
||||
source.route(raw)
|
||||
assertTrue(output.events.contains("key 28 true"))
|
||||
assertFalse(output.events.any { it.startsWith("pointer") })
|
||||
assertTrue(router.tick(.01f)); assertTrue(output.events.contains("pointer 9.0 0.0"))
|
||||
raw.clear(); source.route(raw)
|
||||
assertFalse(router.tick(.01f))
|
||||
}
|
||||
|
||||
@Test fun trackpadUsesContactRelativeDeltasWithoutFirstTouchJump() {
|
||||
router.setProfile(bind(InputControl.RIGHT_PAD, InputAction.Pointer(1000f), InputRole.SECONDARY))
|
||||
val channel = router.channel(InputRole.SECONDARY, InputControl.RIGHT_PAD)
|
||||
assertNull(channel.update(.5f, .5f, true))
|
||||
assertTrue(output.events.isEmpty())
|
||||
channel.update(.6f, .6f, true)
|
||||
assertEquals(1, output.events.size)
|
||||
assertTrue(output.events[0].startsWith("pointer 100."))
|
||||
channel.update(0f, 0f, false)
|
||||
channel.update(-.9f, -.9f, true)
|
||||
assertEquals(1, output.events.size)
|
||||
}
|
||||
|
||||
@Test fun disablingRoutingReleasesKeysAndBlocksHeldInputOnResume() {
|
||||
router.setProfile(bind(InputControl.A, InputAction.Keys(listOf(1))))
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val raw = PadState().apply { press(PadState.A, true) }
|
||||
source.route(raw); router.setEnabled(false); source.route(raw); router.setEnabled(true); source.route(raw)
|
||||
assertEquals(listOf("key 1 true", "key 1 false"), output.events)
|
||||
raw.clear(); source.route(raw); raw.press(PadState.A, true); source.route(raw)
|
||||
assertEquals("key 1 true", output.events.last())
|
||||
}
|
||||
|
||||
@Test fun disabledSourcesDoNotLeakNativeButtons() {
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
router.setEnabled(false)
|
||||
assertFalse(source.route(PadState().apply { press(PadState.A, true) }).isDown(PadState.A))
|
||||
}
|
||||
|
||||
@Test fun lifecycleReleaseAlsoBlocksNativeIdentityRoute() {
|
||||
val source = router.pad(InputRole.PHYSICAL)
|
||||
val raw = PadState().apply { press(PadState.A, true) }
|
||||
source.route(raw); router.releaseAll()
|
||||
assertFalse(source.route(raw).isDown(PadState.A))
|
||||
}
|
||||
|
||||
@Test fun mergedPadKeepsPhysicalButtonAndWholeStickVector() {
|
||||
val physical = PadState().apply { press(PadState.A, true); leftX = .8f; leftY = -.6f }
|
||||
physical.merge(PadState().apply { leftX = -.2f; leftY = .1f })
|
||||
assertTrue(physical.isDown(PadState.A)); assertEquals(.8f, physical.leftX); assertEquals(-.6f, physical.leftY)
|
||||
}
|
||||
|
||||
@Test fun outputOwnershipKeepsKeyboardAndMappingHoldsIndependent() {
|
||||
val held = HeldInput(output::key)
|
||||
held.set(1, 29, true); held.set(4, 29, true); held.release(4)
|
||||
assertEquals(listOf("key 29 true"), output.events)
|
||||
held.clear(); assertEquals("key 29 false", output.events.last())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.After
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], manifest = Config.NONE, shadows = [JoyConPadTest.RecordingWriter::class])
|
||||
class InputSourceOwnershipTest {
|
||||
private val bridge = PadBridge(File(RuntimeEnvironment.getApplication().cacheDir, "input-ownership"))
|
||||
private fun state() = JoyConPadTest.RecordingWriter.pads[0]!!
|
||||
@After fun close() { bridge.stop(); DeckControls.releaseAll(); DeckControls.setRouter(null) }
|
||||
|
||||
@Test fun closingOneSurfaceDoesNotReleaseAnotherSurfaceOrPhysicalPad() {
|
||||
val a = Any(); val b = Any()
|
||||
bridge.applyExternal({ it.press(PadState.A, true) }, true)
|
||||
bridge.applyTouch(a, InputRole.PRIMARY) { it.press(PadState.A, true); it.press(PadState.B, true) }
|
||||
bridge.applyTouch(b, InputRole.SECONDARY) { it.press(PadState.X, true) }
|
||||
bridge.releaseTouch(a)
|
||||
assertTrue(state().isDown(PadState.A)); assertTrue(state().isDown(PadState.X)); assertFalse(state().isDown(PadState.B))
|
||||
bridge.releaseTouch(b)
|
||||
assertTrue(state().isDown(PadState.A)); assertFalse(state().isDown(PadState.X))
|
||||
}
|
||||
|
||||
@Test fun disconnectingSecondaryDeckSurfaceKeepsPhysicalGripHeld() {
|
||||
val surface = DeckControls.source(InputRole.SECONDARY)
|
||||
DeckControls.setGrip(DeckControls.L4, true)
|
||||
surface.setGrip(DeckControls.L4, true)
|
||||
surface.close()
|
||||
assertEquals(DeckControls.L4, JoyConPadTest.RecordingWriter.grips)
|
||||
DeckControls.setGrip(DeckControls.L4, false)
|
||||
assertEquals(0, JoyConPadTest.RecordingWriter.grips)
|
||||
}
|
||||
|
||||
@Test fun nativePadAndTouchMappingsUseDifferentRoles() {
|
||||
val output = object : InputOutput {
|
||||
override fun key(code: Int, down: Boolean) {}
|
||||
override fun button(code: Int, down: Boolean) {}
|
||||
override fun pointer(dx: Float, dy: Float) {}
|
||||
override fun scroll(steps: Int) {}
|
||||
override fun command(command: InputCommand) {}
|
||||
override fun requestMotion() {}
|
||||
}
|
||||
val router = InputRouter(output)
|
||||
bridge.setInputRouter(router)
|
||||
bridge.setInputProfile(InputProfile.defaults(InputProfile.GAME).withBinding(InputRole.SECONDARY, InputControl.A, InputAction.Disabled))
|
||||
val touch = Any()
|
||||
bridge.applyTouch(touch, InputRole.SECONDARY) { it.press(PadState.A, true) }
|
||||
assertFalse(state().isDown(PadState.A))
|
||||
bridge.applyExternal({ it.press(PadState.A, true) }, true)
|
||||
assertTrue(state().isDown(PadState.A))
|
||||
bridge.releaseAll()
|
||||
assertFalse(state().isDown(PadState.A))
|
||||
bridge.applyExternal({ it.press(PadState.B, true) }, true)
|
||||
assertFalse(state().isDown(PadState.A))
|
||||
assertTrue(state().isDown(PadState.B))
|
||||
bridge.applyExternal({ it.press(PadState.A, false) }, true)
|
||||
bridge.applyExternal({ it.press(PadState.A, true) }, true)
|
||||
assertTrue(state().isDown(PadState.A))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.InputDeviceBuilder
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], manifest = Config.NONE, shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class])
|
||||
class PhysicalInputBindingsTest {
|
||||
private val events = ArrayList<String>()
|
||||
private val router = InputRouter(object : InputOutput {
|
||||
override fun key(code: Int, down: Boolean) { events += "$code $down" }
|
||||
override fun button(code: Int, down: Boolean) {}
|
||||
override fun pointer(dx: Float, dy: Float) {}
|
||||
override fun scroll(steps: Int) {}
|
||||
override fun command(command: InputCommand) { events += command.name }
|
||||
override fun requestMotion() {}
|
||||
})
|
||||
private val bindings = PhysicalInputBindings(router)
|
||||
|
||||
private fun device(id: Int): InputDevice {
|
||||
val device = InputDeviceBuilder.newBuilder().setId(id).setName("Thor extra keys").setDescriptor("keys-$id")
|
||||
.setSources(InputDevice.SOURCE_KEYBOARD).setKeyboardType(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC).build()
|
||||
val manager = RuntimeEnvironment.getApplication().getSystemService(InputManager::class.java)
|
||||
shadowOf(manager).addInputDevice(device)
|
||||
return device
|
||||
}
|
||||
private fun event(id: Int, down: Boolean, code: Int = KeyEvent.KEYCODE_F1, repeat: Int = 0) =
|
||||
KeyEvent(0L, 0L, if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP, code, repeat, 0, id, 59, 0, InputDevice.SOURCE_KEYBOARD)
|
||||
|
||||
@Test fun captureReservesSystemKeysButAcceptsKeyboardBackButtons() {
|
||||
val device = device(40)
|
||||
assertNotNull(PhysicalInputKey.capture(event(device.id, true)))
|
||||
assertNull(PhysicalInputKey.capture(event(device.id, true, KeyEvent.KEYCODE_VOLUME_UP)))
|
||||
assertNull(PhysicalInputKey.capture(event(device.id, true, KeyEvent.KEYCODE_HOME)))
|
||||
}
|
||||
|
||||
@Test fun bindingMatchesOnlyItsDeviceAndReleasesAfterEditingProfile() {
|
||||
val device = device(40); val other = device(41)
|
||||
val key = PhysicalInputKey.capture(event(device.id, true))!!
|
||||
bindings.configure(listOf(PhysicalInputBinding(key, InputAction.Keys(listOf(29, 30)))))
|
||||
assertFalse(bindings.onKey(event(other.id, true), true))
|
||||
assertTrue(bindings.onKey(event(device.id, true), true))
|
||||
bindings.configure(listOf(PhysicalInputBinding(key, InputAction.Keys(listOf(28)))))
|
||||
assertTrue(bindings.onKey(event(device.id, true, repeat = 1), true))
|
||||
assertTrue(bindings.onKey(event(device.id, false), true))
|
||||
assertEquals(listOf("29 true", "30 true", "30 false", "29 false"), events)
|
||||
bindings.onKey(event(device.id, true), true)
|
||||
assertEquals("28 true", events.last())
|
||||
}
|
||||
|
||||
@Test fun releasesRemainConsumedAfterOpeningMenuAndVirtualKeysAreUntouched() {
|
||||
val device = device(40)
|
||||
val key = PhysicalInputKey.capture(event(device.id, true))!!
|
||||
bindings.configure(listOf(PhysicalInputBinding(key, InputAction.Keys(listOf(57)))))
|
||||
assertFalse(bindings.onKey(event(-1, true), true))
|
||||
bindings.onKey(event(device.id, true), true)
|
||||
bindings.release()
|
||||
assertTrue(bindings.onKey(event(device.id, false), false))
|
||||
assertEquals(listOf("57 true", "57 false"), events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class TritonInputRouterTest {
|
||||
private val events = ArrayList<String>()
|
||||
private val router = InputRouter(object : InputOutput {
|
||||
override fun key(code: Int, down: Boolean) { events += "$code $down" }
|
||||
override fun button(code: Int, down: Boolean) {}
|
||||
override fun pointer(dx: Float, dy: Float) {}
|
||||
override fun scroll(steps: Int) {}
|
||||
override fun command(command: InputCommand) {}
|
||||
override fun requestMotion() {}
|
||||
})
|
||||
|
||||
@Test fun nativeReportIsPassedThroughWithoutCopyingOrReencoding() {
|
||||
val adapter = TritonInputRouter(router)
|
||||
val bytes = ByteArray(64) { (it * 11).toByte() }
|
||||
assertSame(bytes, adapter.route(bytes))
|
||||
assertTrue(events.isEmpty())
|
||||
adapter.close()
|
||||
}
|
||||
|
||||
@Test fun consumedButtonDoesNotChangeOtherBitsAxesPressureMotionOrExtension() {
|
||||
router.setProfile(InputProfile.defaults(InputProfile.GAME).withBinding(InputRole.PHYSICAL, InputControl.A, InputAction.Keys(listOf(57))))
|
||||
val adapter = TritonInputRouter(router)
|
||||
val bytes = ByteArray(64) { (it * 7).toByte() }
|
||||
bytes[1] = (bytes[1].toInt() or 1).toByte()
|
||||
val expected = bytes.copyOf().apply { this[1] = (this[1].toInt() and 1.inv()).toByte() }
|
||||
assertArrayEquals(expected, adapter.route(bytes))
|
||||
assertEquals(listOf("57 true"), events)
|
||||
adapter.release()
|
||||
assertEquals(listOf("57 true", "57 false"), events)
|
||||
}
|
||||
|
||||
@Test fun switchingProfileWhileNativeButtonHeldRequiresRelease() {
|
||||
val adapter = TritonInputRouter(router)
|
||||
val held = ByteArray(45).apply { this[1] = 1 }
|
||||
adapter.route(held)
|
||||
router.setProfile(InputProfile.defaults(InputProfile.GAME).withBinding(InputRole.PHYSICAL, InputControl.A, InputAction.Keys(listOf(57))))
|
||||
adapter.route(held)
|
||||
assertTrue(events.isEmpty())
|
||||
adapter.route(ByteArray(45)); adapter.route(held)
|
||||
assertEquals(listOf("57 true"), events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.*
|
||||
import com.droiddeck.launcher.input.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], qualifiers = "w1000dp-h900dp")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class InputSettingsTest {
|
||||
@get:Rule val compose = createRobolectricComposeRule()
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
@Before fun reset() {
|
||||
context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("input-surfaces", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
context.getSharedPreferences("controller", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun bindingEditorPersistsDisabledWithoutChangingTouchControls() {
|
||||
var changed = false
|
||||
compose.setContent { DroidDeckTheme { InputSettingsPage({}, { changed = true }) } }
|
||||
compose.onNodeWithTag("input-binding-a").performScrollTo().performClick()
|
||||
compose.onNodeWithText("Disabled", substring = false).performScrollTo().performClick()
|
||||
compose.runOnIdle {
|
||||
assertTrue(changed)
|
||||
val profile = InputProfiles.read(context, InputProfile.GAME)
|
||||
assertEquals(InputAction.Disabled, profile.action(InputRole.PHYSICAL, InputControl.A))
|
||||
assertEquals(InputAction.Native(), profile.action(InputRole.SECONDARY, InputControl.A))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun customLayoutAddsExistingControlAndKeepsProfileBindings() {
|
||||
var changed = false
|
||||
compose.setContent { DroidDeckTheme { InputSurfaceDialog(InputProfile.GAME, {}, { changed = true }) } }
|
||||
compose.onNodeWithText("Add control").performScrollTo().performClick()
|
||||
compose.onNodeWithText("R1", substring = false).performScrollTo().performClick()
|
||||
compose.onNodeWithText("Save changes").performClick()
|
||||
compose.runOnIdle {
|
||||
assertTrue(changed)
|
||||
assertTrue(InputSurfaceLayout.read(context, InputProfile.GAME).any { it.control == InputControl.RB })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
# Unified input configuration and routing proposal
|
||||
|
||||
Status: implemented locally for device evaluation; not published or approved by maintainers.
|
||||
Prepared on 2026-10-23 against `ac0909ea`.
|
||||
Related source snapshots: PR #1202 at `42db788f`, PR #1305 at `76fc9b31`.
|
||||
|
||||
## Problem and scope
|
||||
|
||||
DroidDeck has useful input paths whose configuration and lifecycle rules are
|
||||
spread across several components. Desktop users need configurable physical
|
||||
controller bindings. Dual-screen users also need to arrange and configure lower
|
||||
screen controls without giving up Steam Deck trackpads or Steam Input layouts.
|
||||
|
||||
Provide one configuration model and reusable action handling while preserving
|
||||
the existing low-latency transports and Steam's ownership of Steam-managed
|
||||
bindings. A common model does not require all input to cross a new queue, UI
|
||||
thread, generic event bus, or virtual device.
|
||||
|
||||
The implementation has two editable built-in profiles, Game and
|
||||
Desktop. Named profile creation, per-application selection, and profile
|
||||
import/export are future work; the stored model must allow them without changing
|
||||
the routing contract.
|
||||
|
||||
## Existing paths to preserve
|
||||
|
||||
| Source | Current path | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Android physical pad | `PadBridge` -> `FakeInputWriter` | Gamepad buttons, axes, player slots |
|
||||
| Built-in screen pad, including the lower-screen Gamepad tab | `PadBridge.applyTouch` -> `FakeInputWriter` | Virtual gamepad input |
|
||||
| Lower-screen Deck trackpads and back grips | `DeckControlsPanel` -> `DeckControls` -> `FakeInputWriter.writeDeckControls` | Touch coordinates, contact, click/pressure, L4/L5/R4/R5 |
|
||||
| Steam touch overlay | `SteamTouchControls` -> `SteamTouchDevice` -> Steam | Steam Mobile Touch reports, with Steam-owned bindings and action sets |
|
||||
| Desktop trackpad and keyboard | Existing gesture/keyboard handlers -> compositor | Direct guest mouse and keyboard input |
|
||||
| Steam Controller BLE and proposed native DualSense | Driver-specific report paths | Input can bypass Android `KeyEvent` dispatch |
|
||||
|
||||
Deck trackpads must not be reduced to mouse deltas or a pair of ordinary joystick
|
||||
axes. Their contact, coordinates, click state and pressure are meaningful to
|
||||
Steam. Likewise, Steam Mobile Touch is a distinct protocol, not another spelling
|
||||
of an Xbox pad.
|
||||
|
||||
See [Steam touch controls](steam-touch-controls.md) for the current integration,
|
||||
including Steam-owned layouts, action sets and cloud configuration.
|
||||
|
||||
## Separate bindings, screen layout, and device calibration
|
||||
|
||||
The proposed model has three independent parts:
|
||||
|
||||
- **Binding profile:** logical input sources and their actions, with Game and
|
||||
Desktop defaults and user overrides.
|
||||
- **Surface layout:** which controls appear on the primary or secondary control
|
||||
surface, their positions, sizes and presentation. A layout references bindings
|
||||
or Steam-managed controls; it does not copy Steam's action-set definitions.
|
||||
- **Device configuration:** hardware identification, axis normalization,
|
||||
calibration and capabilities. This is separate from a user's desktop layout.
|
||||
|
||||
Use logical surface roles rather than persistent Android display IDs. Controls
|
||||
need stable IDs independent of their location. Moving a control to the lower
|
||||
screen must not change its action or create a new guest gamepad. The existing
|
||||
player-slot and device identities remain authoritative.
|
||||
|
||||
A Game profile can use the lower screen for two Deck trackpads and back grips,
|
||||
plus selected DroidDeck actions. A Desktop profile can put a mouse trackpad and
|
||||
keyboard there while mapping the physical sticks to arrow keys, scrolling or
|
||||
other actions. Connecting a second display must not silently rewrite physical
|
||||
controller bindings.
|
||||
|
||||
The model should support reusable lower-screen layouts later. Initially expose
|
||||
existing layouts and a bounded set of configurable controls; a complete freeform
|
||||
multi-display editor is a separate deliverable.
|
||||
|
||||
## Steam Input ownership
|
||||
|
||||
Each binding explicitly selects an execution path:
|
||||
|
||||
1. **Native/Steam-managed:** send the existing controller or touch report.
|
||||
Steam remains responsible for game bindings, action sets, layers and its
|
||||
touch/radial menus. DroidDeck shows an appropriate entry into Steam's editor.
|
||||
2. **DroidDeck-managed:** execute a guest key combination, mouse action or
|
||||
application action through the existing output functions. The consumed
|
||||
control is excluded from the normal controller output.
|
||||
3. **Disabled:** intentionally produce no action.
|
||||
|
||||
Steam-managed and DroidDeck-managed controls can coexist on one surface. One
|
||||
activation must have one semantic owner, so a local keyboard shortcut does not
|
||||
also produce the original controller button. Gamepad-to-gamepad remaps remain
|
||||
explicit preprocessing before the normal game/Steam path, rather than a second
|
||||
independent action sent alongside it.
|
||||
|
||||
Steam Input already supports extensive binding and touch-menu behavior. This
|
||||
proposal concentrates DroidDeck's work on Android surfaces, app shortcuts,
|
||||
desktop/non-Steam bindings and consistent lifecycle handling. It does not propose
|
||||
reimplementing Steam's configuration engine or importing its profiles into a
|
||||
second authoritative store.
|
||||
|
||||
Preserve raw-report fields and output/feedback channels not affected by a local
|
||||
binding, including motion and haptics. Driver-specific adapters must participate
|
||||
before publishing consumed controls; an Activity-only interceptor cannot cover
|
||||
raw HID devices.
|
||||
|
||||
## Configuration and runtime model
|
||||
|
||||
Responsibilities:
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| `InputProfile` | Version, stable ID, default role, bindings and surface-layout references |
|
||||
| `SurfaceLayout` | Stable control IDs, display role, geometry and appearance |
|
||||
| `InputBinding` | Source selector, activation rule, action and execution owner |
|
||||
| Source adapters | Preserve device identity, normalize inputs and expose capabilities |
|
||||
| Compiled binding plan | Pre-resolved routes for one active profile and its devices |
|
||||
| Action executor/output ownership | Execute existing outputs and track held contributions |
|
||||
|
||||
Buttons and axis directions can produce keys, key combinations, mouse buttons,
|
||||
gamepad buttons or app actions. Sticks can retain their gamepad role or produce
|
||||
pointer velocity, scrolling or keyboard directions. Analog triggers need distinct
|
||||
press/release thresholds when used as buttons. Capability-aware controls should
|
||||
explain why an unavailable action cannot run, rather than silently substituting
|
||||
another behavior.
|
||||
|
||||
The same action picker is reused for physical and local screen controls. Screen
|
||||
geometry stays outside binding execution. Steam-managed widgets retain their
|
||||
native configuration and protocol adapters.
|
||||
|
||||
Configuration is loaded, validated and compiled when it changes. Dispatch uses
|
||||
an immutable plan snapshot and preallocated per-source state. JSON decoding,
|
||||
preference reads, disk writes and layout traversal do not belong in event dispatch.
|
||||
Use stable symbolic control/action identifiers in storage; transient Android
|
||||
device IDs are runtime keys, not portable profile identifiers.
|
||||
|
||||
Profile selection defaults to the session's mode, with an explicit session
|
||||
override. Desktop mode can contain a game, so it is not sufficient evidence for
|
||||
automatic keyboard/mouse interception. An optional profile-switch action uses
|
||||
the same app-action mechanism as menus and keyboard display.
|
||||
|
||||
## Ownership and lifecycle
|
||||
|
||||
- Track held keys and mouse buttons by source. Releasing one source must not
|
||||
release a key or button another source still owns.
|
||||
- Cancel only a disappearing surface's or disconnected device's contributions;
|
||||
avoid a global release that also clears another screen or controller.
|
||||
- Define arbitration for multiple sources targeting the same analog control or
|
||||
Deck touch surface. Retain ownership through a contact; do not let concurrent
|
||||
views overwrite each other's reports or add a second virtual pad implicitly.
|
||||
- Changing a binding/profile releases its old outputs before installing the new
|
||||
route. Held controls must return to neutral before triggering a new binding.
|
||||
- Menus, keyboard capture, pause, focus loss and device removal have explicit
|
||||
routing rules. Menu navigation retains working confirmation, cancellation and
|
||||
page controls even when those buttons have gameplay bindings.
|
||||
- Keep system-reserved Android inputs with the system and retain touchscreen
|
||||
access to reset a problematic controller assignment.
|
||||
|
||||
Button chords require a deliberate policy. A dedicated modifier can select a
|
||||
chord without delaying unrelated controls. A button that also has a single-press
|
||||
action may require deferred dispatch to disambiguate a chord, hold or double tap.
|
||||
That delay must be opt-in, visible in configuration and limited to the affected
|
||||
binding. Do not buffer all controller presses to support optional chords.
|
||||
|
||||
## Latency requirements and verification
|
||||
|
||||
The goal is no additional scheduling stage for normal input, not an unmeasured
|
||||
claim of zero added latency.
|
||||
|
||||
- Identity routes retain the existing direct publication path and report format.
|
||||
- Ordinary buttons and touch reports must not wait for drawing, Compose state,
|
||||
a new coroutine, a timer, an event-bus subscriber or another frame.
|
||||
- Add no hot-path allocations for ordinary binding lookup. Reuse buffers and
|
||||
keep critical sections short. The current router serializes routing and profile
|
||||
changes under one monitor to keep shared output ownership consistent; hardware
|
||||
traces must establish whether this causes contention before publication.
|
||||
- Continuous stick-to-pointer movement needs a clock even when no new stick
|
||||
event arrives. Keep that scheduler limited to the active synthesized movement
|
||||
route; stop it at neutral. It must not schedule ordinary gamepad forwarding.
|
||||
- Keep existing synchronization and low-latency flags intact until measurements
|
||||
justify a separate change. Do not weaken shared-memory publication ordering.
|
||||
- Send UI updates separately from input publication. Marshaling app-menu work to
|
||||
Android's UI thread must not marshal ordinary controller output with it.
|
||||
|
||||
Before publishing the change, record a baseline on Thor with the same build
|
||||
configuration, controller mode, display refresh rates and low-latency settings.
|
||||
Compare identity forwarding, a local key binding, stick-to-pointer, Deck trackpad
|
||||
input and simultaneous upper/lower-screen input under both idle and rendering
|
||||
load. Measure p50/p95/p99 dispatch-to-publication time, allocations and contention.
|
||||
Use debug-only sampling or buffered tracing; avoid per-event log writes.
|
||||
|
||||
Android-to-publication measurements do not measure complete button-to-photon
|
||||
latency. Guest receipt and rendered response need separate measurement, with
|
||||
clock-domain alignment for cross-process traces or an external high-speed-camera
|
||||
test. Record benchmark conditions and uncertainty; establish numerical acceptance
|
||||
budgets from the baseline before presenting latency claims in a PR.
|
||||
|
||||
Hard acceptance conditions include no new frame/timer wait for identity routes,
|
||||
no duplicate or lost press/release transitions, and no degradation of current
|
||||
Deck trackpad behavior. Microbenchmarks alone cannot establish those conditions.
|
||||
|
||||
## Reuse, migration, and removal
|
||||
|
||||
- [#1202](https://github.com/Droid-Deck/DroidDeck/pull/1202): reuse physical capture,
|
||||
device identification and menu-action UI where suitable. Generalize the
|
||||
execution contract beyond Activity `KeyEvent` interception.
|
||||
- [#1305](https://github.com/Droid-Deck/DroidDeck/pull/1305): reuse PC-key definitions,
|
||||
key selection and screen-control presentation. Adapt keyboard output to shared
|
||||
ownership; its screen-layout format is not a complete physical-input profile.
|
||||
- [#581](https://github.com/Droid-Deck/DroidDeck/pull/581): coordinate device
|
||||
calibration/profile matching, independently of user binding/surface profiles.
|
||||
- [#1226](https://github.com/Droid-Deck/DroidDeck/pull/1226): use a shared keyboard
|
||||
action with the appropriate existing host/display, rather than hard-coding a
|
||||
second keyboard implementation.
|
||||
- Reuse current guest keyboard/mouse output, `PadBridge`, Deck/Steam touch
|
||||
transports and the existing second-screen presentation lifecycle.
|
||||
|
||||
Remove duplicated settings, capture UI and action switches only after their
|
||||
replacement preserves saved settings and passes behavior tests. Do not remove
|
||||
working device adapters, Steam touch support or native transport code merely to
|
||||
make the architecture diagram more uniform. Stage each migration so it can be
|
||||
reviewed and reverted independently.
|
||||
|
||||
## Implemented local evaluation build
|
||||
|
||||
No PR is created yet. Test the complete integrated change on Thor before deciding
|
||||
whether to publish it. The intended framing is **Shared input profiles and
|
||||
configurable control surfaces**, with links to the desktop requests
|
||||
[#860](https://github.com/Droid-Deck/DroidDeck/issues/860) and
|
||||
[#1356](https://github.com/Droid-Deck/DroidDeck/issues/1356), the centralization
|
||||
discussion in #1202, and the related work above. Those discussions are context,
|
||||
not maintainer approval of this implementation.
|
||||
|
||||
### Runtime and configuration
|
||||
|
||||
- `InputProfile` separates actions and source roles from `InputSurfaceLayout`.
|
||||
`InputProfiles` stores a versioned document and migrates legacy touch mappings
|
||||
and Select-as-Steam into Game once. Editing one role does not edit another.
|
||||
- `InputRouter` resolves actions when a profile changes. `PadSource` returns the
|
||||
original `PadState` on the identity path. Other routes reuse their output
|
||||
buffers and consume only the selected control. A key combination presses
|
||||
modifiers first and releases in reverse order. Trigger/direction hysteresis
|
||||
uses separate press and release thresholds.
|
||||
- `PadBridge` preserves physical player slots and separately owns each touch
|
||||
surface. Merging uses OR for buttons, maximum trigger values and the strongest
|
||||
complete stick vector. Surface removal cannot release another source's hold.
|
||||
- `DeckControls` keeps per-source contacts, coordinates, clicks and pressure.
|
||||
Default native actions retain the existing Deck report. Local trackpad actions
|
||||
use contact-relative deltas, avoiding a jump at first contact.
|
||||
- `TritonInputRouter` routes Steam Controller BLE reports before publication. Its
|
||||
identity path returns the original byte array. Other paths preserve packet,
|
||||
timestamp, inertial data, grip-contact bits, report extensions and pressure.
|
||||
This adapter is covered by synthetic report tests; physical BLE testing is
|
||||
still required.
|
||||
- `PhysicalInputBindings` captures device identity, keycode and scancode, including
|
||||
controller buttons exposed as keyboard keys. It consumes a configured local
|
||||
action before normal dispatch and keeps releases paired across profile edits.
|
||||
Android volume and system keys remain reserved.
|
||||
- `HeldInput` coordinates local mappings with existing guest keyboard, gesture,
|
||||
mouse and second-screen outputs. `InputMotion` schedules only synthesized
|
||||
stick movement/scrolling. Buttons and trackpad deltas publish synchronously;
|
||||
UI commands use the existing main-thread handler.
|
||||
- Profile changes, suspension and menu entry release local outputs and require
|
||||
held inputs to return to neutral before activating their new actions.
|
||||
|
||||
### User interface
|
||||
|
||||
Input profiles are available from Controller setup and the session's Controller
|
||||
panel. Auto selects Desktop for a desktop session and Game for other sessions;
|
||||
manual selection handles games launched from a desktop without guessing from
|
||||
window focus. The editor exposes physical, primary-screen and secondary-screen
|
||||
roles, native/remapped gamepad output, local actions and disabled bindings.
|
||||
|
||||
Custom controls is an additional second-screen mode. Its editor arranges a
|
||||
bounded set of existing `DeckControlsPanel` buttons, sticks, trackpads and grips.
|
||||
Layout geometry is independent of bindings and saved per built-in profile.
|
||||
Android keyboard input reuses `KeyboardHost`; the existing keyboard/trackpad
|
||||
and Deck panel modes remain available. Shared PC-key definitions are taken from
|
||||
PR #1305 at `76fc9b31`; capture/identity handling follows PR #1202 at `42db788f`.
|
||||
Neither PR is merged wholesale. The superseded touch-only mapping page and
|
||||
Select-as-Steam UI toggle are removed after migration.
|
||||
|
||||
### Boundaries and deferred work
|
||||
|
||||
Steam Mobile Touch retains its Steam-owned action sets, layouts and cloud data;
|
||||
its controls are edited in Steam. It does not pass through DroidDeck's local
|
||||
remapper. Existing gesture recognition and VR controller handling are retained;
|
||||
gestures share output ownership, while the VR-specific input path remains owned
|
||||
by VR. Native remaps are limited to compatible controls within the same report
|
||||
family. An Android physical key can retain its original behavior or run a local
|
||||
action; capture does not synthesize an unrelated hardware device.
|
||||
|
||||
Named profile libraries, importing/exporting profiles, per-app switching,
|
||||
reusable layout presets, macros, tap/hold/chord activation and a freeform
|
||||
multi-display drag editor remain deferred. Key combinations emitted by one
|
||||
binding are supported; multi-button activation chords are not implemented.
|
||||
Hardware calibration stays in the existing adapters. No native transport,
|
||||
Steam haptics path or device driver is replaced.
|
||||
|
||||
### Validation and Thor procedure
|
||||
|
||||
Before changes, the existing controller/PadMouse/on-screen/second-screen tests
|
||||
passed. The integrated suite exercises routing, shared holds, key order,
|
||||
hysteresis, neutral gating, profile migration/persistence, raw report preservation,
|
||||
existing input paths and the Compose binding/layout editors. See the local
|
||||
Gradle test report for exact results.
|
||||
|
||||
1. Install the local test APK alongside the signed public app; keep runtime data
|
||||
and baseline configuration available. Do not replace the official signing key.
|
||||
2. In a Desktop session, test pointer/scroll, mouse buttons, arrow keys, arbitrary
|
||||
key combinations and captured hardware back buttons. Open and close the drawer
|
||||
and keyboard with controls held; verify every guest release.
|
||||
3. Select Game and test Factorio plus Steam UI, first with the default native
|
||||
profile, then with individual bindings changed to local or disabled. Repeat
|
||||
with Xbox 360 and Steam Deck virtual-controller modes.
|
||||
4. Exercise keyboard/trackpad, Deck panels and Custom controls on the lower screen.
|
||||
Hold the same output from two sources, remove a surface, change profiles while
|
||||
holding sticks/buttons and reconnect the controller/display.
|
||||
5. Enable Input diagnostics, close the editor, generate input under idle and
|
||||
rendering load, then reopen and read p50/p95/p99. Recording is bounded to the
|
||||
most recent 4096 samples and disabled by default. `InputTiming` measures dispatch
|
||||
through existing transport publication, including routing. It does not measure
|
||||
full button-to-photon latency. Use the same instrumentation on a baseline build
|
||||
before making comparative claims.
|
||||
6. Record refresh rates, controller/transport mode, build type and low-latency
|
||||
flags. Compare raw identity, local key, pointer and native Deck routes. Use
|
||||
external high-speed video or aligned guest traces for end-to-end measurement.
|
||||
|
||||
A passing host test suite does not establish latency on Thor or physical BLE
|
||||
compatibility. Those remain explicit release gates before a public PR.
|
||||
Reference in new issue
Block a user