feat: three-finger tap toggles Steam's on-screen keyboard in game mode

This commit is contained in:
xXJsonDeruloXx committed 2026-10-13 23:48:20 -04:00
1 parent 06bd0de4d7
commit ffab8d7568
4 files changed
+179 -12

No files matched your search

@@ -162,6 +162,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private var watching = true
private lateinit var touchpad: TouchpadGestures
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
private var cursorVisible by mutableStateOf(false)
@@ -397,6 +398,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
keyboard = KeyboardHost(this).also { root.addView(it) }
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
val noCursor = android.view.PointerIcon.getSystemIcon(this, android.view.PointerIcon.TYPE_NULL)
root.pointerIcon = noCursor
surfaceView.pointerIcon = noCursor
@@ -1954,6 +1956,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true
if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") {
val wasActive = threeFingerTap.active
if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard()
if (threeFingerTap.active && !wasActive) {
if (usingTouchpad()) touchpad.cancel() else WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
}
if (threeFingerTap.active || wasActive) return true
}
if (usingTouchpad()) {
val rect = drawnRect() ?: return false
if (touchpad.bounds != rect) {
@@ -54,7 +54,8 @@ public final class PadBridge {
private boolean systemGuidePressed;
private boolean systemQamPressed;
private boolean qamChordActive;
private boolean qamSyntheticAPressed;
private int chordButton = -1;
private boolean chordButtonPressed;
private boolean qamTapPressed;
private int qamChordGeneration;
private volatile Runnable onPlayerInput;
@@ -113,7 +114,7 @@ public final class PadBridge {
systemGuidePressed = false;
systemQamPressed = false;
qamChordActive = false;
qamSyntheticAPressed = false;
chordButtonPressed = false;
qamTapPressed = false;
qamChordGeneration++;
if (open) {
@@ -317,7 +318,7 @@ public final class PadBridge {
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
systemQamPressed = qamPressed;
if (qamStarted && !nativeQam()) startQamChord();
if (qamStarted && !nativeQam()) startChord(PadState.A);
else publish();
}
@@ -343,7 +344,7 @@ public final class PadBridge {
public synchronized void triggerQam() {
if (!nativeQam()) {
startQamChord();
startChord(PadState.A);
return;
}
if (qamTapPressed) return;
@@ -359,12 +360,17 @@ public final class PadBridge {
publish();
}
private void startQamChord() {
public synchronized void triggerSteamKeyboard() {
startChord(PadState.X);
}
private void startChord(int button) {
if (qamChordActive) return;
qamChordActive = true;
chordButton = button;
int generation = ++qamChordGeneration;
publish();
chordHandler.postDelayed(() -> pressQamA(generation), qamGuideLeadMs());
chordHandler.postDelayed(() -> pressChordButton(generation), qamGuideLeadMs());
}
static long qamGuideLeadMs() {
@@ -373,16 +379,16 @@ public final class PadBridge {
return Math.max(QAM_GUIDE_LEAD_MIN_MS, Math.min(QAM_GUIDE_LEAD_MAX_MS, frame * QAM_GUIDE_LEAD_FRAMES));
}
private synchronized void pressQamA(int generation) {
private synchronized void pressChordButton(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = true;
chordButtonPressed = true;
publish();
chordHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
chordHandler.postDelayed(() -> releaseChordButton(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamA(int generation) {
private synchronized void releaseChordButton(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = false;
chordButtonPressed = false;
publish();
chordHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
}
@@ -491,7 +497,7 @@ public final class PadBridge {
effectiveState.copyFrom(state);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
if (chordButtonPressed) effectiveState.press(chordButton, true);
writer.writePad(effectiveState);
}
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.input
import android.view.MotionEvent
import kotlin.math.hypot
class ThreeFingerTap(private val slopPx: Float) {
var active = false
private set
private val starts = HashMap<Int, Pair<Float, Float>>()
private var downAt = 0L
private var threeAt = 0L
private var valid = false
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
starts.clear()
active = false
valid = true
downAt = event.eventTime
note(event, event.actionIndex)
}
MotionEvent.ACTION_POINTER_DOWN -> {
note(event, event.actionIndex)
if (event.pointerCount > 3) valid = false
if (event.pointerCount == 3 && !active) {
active = true
threeAt = event.eventTime
if (threeAt - downAt > LAND_MS) valid = false
}
}
MotionEvent.ACTION_MOVE -> for (i in 0 until event.pointerCount) {
val start = starts[event.getPointerId(i)] ?: continue
if (hypot(event.getX(i) - start.first, event.getY(i) - start.second) > slopPx) valid = false
}
MotionEvent.ACTION_UP -> {
val fired = active && valid && event.eventTime - threeAt < TAP_MS
active = false
return fired
}
MotionEvent.ACTION_CANCEL -> active = false
}
return false
}
private fun note(event: MotionEvent, index: Int) {
starts[event.getPointerId(index)] = event.getX(index) to event.getY(index)
}
private companion object {
const val LAND_MS = 250L
const val TAP_MS = 350L
}
}
@@ -0,0 +1,96 @@
package com.droiddeck.launcher.input
import android.view.MotionEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class ThreeFingerTapTest {
private val tap = ThreeFingerTap(8f)
private var time = 1000L
private var downTime = 0L
private val a = 1 to (400f to 300f)
private val b = 2 to (500f to 300f)
private val c = 3 to (600f to 300f)
@Test fun quickThreeFingerTapFires() {
assertEquals(listOf(false, false, false, false, false, true), threeFingerTap())
}
@Test fun activeOnceTheThirdFingerLandsUntilTheLastLifts() {
touch(MotionEvent.ACTION_DOWN, 0, a)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b)
assertFalse(tap.active)
touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c)
assertTrue(tap.active)
touch(MotionEvent.ACTION_POINTER_UP, 2, a, b, c)
touch(MotionEvent.ACTION_POINTER_UP, 1, a, b)
assertTrue(tap.active)
touch(MotionEvent.ACTION_UP, 0, a)
assertFalse(tap.active)
}
@Test fun twoFingerTapDoesNotFire() {
touch(MotionEvent.ACTION_DOWN, 0, a)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b)
touch(MotionEvent.ACTION_POINTER_UP, 1, a, b)
assertFalse(touch(MotionEvent.ACTION_UP, 0, a))
}
@Test fun heldTooLongDoesNotFire() {
assertFalse(threeFingerTap(hold = 600).last())
}
@Test fun swipeDoesNotFire() {
touch(MotionEvent.ACTION_DOWN, 0, a)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b)
touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c)
touch(MotionEvent.ACTION_MOVE, 0, a, b, 3 to (600f to 400f))
touch(MotionEvent.ACTION_POINTER_UP, 2, a, b, c)
touch(MotionEvent.ACTION_POINTER_UP, 1, a, b)
assertFalse(touch(MotionEvent.ACTION_UP, 0, a))
}
@Test fun fingersLandingFarApartDoNotFire() {
touch(MotionEvent.ACTION_DOWN, 0, a)
time += 500
touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b)
touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c)
touch(MotionEvent.ACTION_POINTER_UP, 2, a, b, c)
touch(MotionEvent.ACTION_POINTER_UP, 1, a, b)
assertFalse(touch(MotionEvent.ACTION_UP, 0, a))
}
private fun threeFingerTap(hold: Long = 100): List<Boolean> {
val out = mutableListOf<Boolean>()
out += touch(MotionEvent.ACTION_DOWN, 0, a)
time += 20
out += touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b)
time += 20
out += touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c)
time += hold
out += touch(MotionEvent.ACTION_POINTER_UP, 2, a, b, c)
out += touch(MotionEvent.ACTION_POINTER_UP, 1, a, b)
out += touch(MotionEvent.ACTION_UP, 0, a)
return out
}
private fun touch(action: Int, index: Int, vararg pointers: Pair<Int, Pair<Float, Float>>): Boolean {
if (action == MotionEvent.ACTION_DOWN) downTime = time
val properties = pointers.map { (id, _) ->
MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER }
}.toTypedArray()
val coords = pointers.map { (_, at) ->
MotionEvent.PointerCoords().apply { x = at.first; y = at.second; pressure = 1f; size = 1f }
}.toTypedArray()
val masked = action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)
val event = MotionEvent.obtain(downTime, time, masked, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0)
return tap.onTouch(event).also { event.recycle() }
}
}