Unify input profiles and configurable controller surfaces

This commit is contained in:
delete committed 2026-10-24 01:43:34 +03:00
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()) {
+50
View File
@@ -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 })
}
}
}
+322
View File
@@ -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.