Files
DroidDeck/tools/tests/test_gamescope_pointer_hold.py
MaxsTechReview 2ade269be3 fix(gamescope): direct-touch drags work again in games that confine a hidden cursor (#2220)
* gamescope: a confine is relative only while it holds the X pointer

Patch 0128 made every hidden-cursor confine relative. Games that hide the
system cursor, draw their own and confine the pointer to their window then
took the host lock, and a direct touch drag became camera motion with no
button held, so cards and items could not be dragged.

A confine now goes relative once the X pointer stays away from the seat
position while input moves, as when a game recentres it or clips it to a
small rectangle, and returns to absolute when the pointer follows again or
the confine ends. Locks and the warp follow rule are unchanged.

* gamescope: a constraint without a hint is relative only while the game holds the X pointer

Xwayland turns a hidden-cursor grab into a lock with no cursor position hint, so the confine-only rule never applied to games that draw their own cursor (Stardew Valley, Balatro). A hinted constraint is relative at once, as upstream. An unhinted lock or confine goes relative once the X pointer stops following the seat's motion, which a recentring or clipping game does and a drawn-cursor game does not.

* gamescope: a pointer constraint is relative only while the game holds the X pointer

Stardew Valley's grab with a hidden cursor becomes an Xwayland lock with a cursor position hint, which upstream made relative at once. Without relative mode that lock still follows gamescope's relative motion and touches still place the pointer, so the hold rule now decides for every constraint, hinted or not.

* gamescope: a hinted lock starts relative and is released once the X pointer follows

Releasing a hinted lock only through the hold rule let a recentring game's first touch swipe press the button. A hinted lock now starts relative as upstream and is released after 30 moving frames on which the X pointer follows the motion gamescope sends, counted from the relative motion while the seat is locked.

* gamescope: count away frames within one half-second window

* gamescope: a hint that arrives with the game's first warp starts the lock relative

* fix(input): end the touch press when a program locks the pointer mid-swipe

A game that only shows its pointer lock after its first warp took the first
swipe as an absolute drag, with the button held until the finger lifted.
The first locked move now ends the forwarded touch before sending deltas.

* gamescope: a new focus window or constraint starts its own pointer hold

A held state from one constrained window carried over to the next one
when focus moved before a reset frame. Track the input focus window and
the constraint, and restart the hold when either changes.

* gamescope: read the pointer constraint under the wlserver lock

GetCursorConstraint asserts the lock is held, so the hold tracker
aborted as soon as a program constrained the pointer.

* fix(input): a mid-swipe pointer lock ends the touch but keeps a held button

The first locked move cancelled the whole direct-touch hold, releasing a
right button held by a long press. It now cancels only the forwarded touch;
the held button is released when the finger lifts, as before.

* fix(input): extra fingers during a locked swipe stay out of the cancelled touch

Once a locked swipe has ended the forwarded touch, a second finger restarted
it with a new touch-down while the swipe carried on as relative motion. Extra
fingers are now ignored until the swipe ends; the final lift still releases a
held button.

* fix(input): a swipe that ended its touch stays closed until the finger lifts
2026-11-07 21:30:21 -05:00

148 lines
5.7 KiB
Python

from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
PATCH = ROOT / "tools/gamescope/patches/0133-constraint-relative-when-held.patch"
def added_source():
return "\n".join(line[1:] for line in PATCH.read_text().splitlines() if line.startswith("+") and not line.startswith("+++"))
def block(text, start):
begin = text.index(start)
return text[begin:text.index("\n}", begin) + 3]
def rules():
text = added_source()
return "\n".join([
block(text, "struct PointerHold"),
block(text, "static bool pointer_hold_step"),
])
PREAMBLE = r'''
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <cstdio>
'''
MAIN = r'''
static const uint64_t FRAME = 16'666'667ul;
static bool run(PointerHold &state, int frames, double seatDX, double seatDY, double pointerDX, double pointerDY, uint64_t &now, bool constrained = true) {
bool held = state.bHeld;
for (int i = 0; i < frames; i++) {
now += FRAME;
held = pointer_hold_step(state, constrained, seatDX, seatDY, pointerDX, pointerDY, now);
}
return held;
}
int main() {
uint64_t now = 1'000'000'000ul;
PointerHold cursorGame;
assert(!run(cursorGame, 600, 12.0, -9.0, 12.0, -9.0, now));
assert(!run(cursorGame, 600, 40.0, 3.0, 39.0, 3.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 0.0, 0.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 30.0, 0.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 0.0, 0.0, now));
assert(!run(cursorGame, 120, 2.0, 1.0, 0.0, 0.0, now));
PointerHold offset;
assert(!run(offset, 600, 25.0, 25.0, 25.0, 25.0, now));
PointerHold recentring;
assert(!run(recentring, 2, -40.0, 12.0, 0.0, 0.0, now));
assert(run(recentring, 1, -40.0, 12.0, 1.0, 0.0, now));
assert(run(recentring, 600, 0.0, 0.0, 0.0, 0.0, now));
assert(run(recentring, 29, 10.0, 4.0, 10.0, 4.0, now));
assert(run(recentring, 1, 20.0, 0.0, 0.0, 0.0, now));
assert(run(recentring, 29, 10.0, 4.0, 10.0, 4.0, now));
assert(!run(recentring, 1, 10.0, 4.0, 10.0, 4.0, now));
PointerHold clipped;
assert(run(clipped, 3, 0.0, 30.0, 0.0, 2.0, now));
PointerHold spaced;
for (int i = 0; i < 10; i++) {
assert(!run(spaced, 1, 50.0, 0.0, 0.0, 0.0, now));
now += 600'000'000ul;
}
PointerHold hinted;
hinted.bHeld = true;
assert(run(hinted, 29, 0.0, 15.0, 0.0, 15.0, now));
assert(!run(hinted, 1, 0.0, 15.0, 0.0, 15.0, now));
PointerHold hintedCamera;
hintedCamera.bHeld = true;
assert(run(hintedCamera, 600, 30.0, -8.0, 0.0, 0.0, now));
PointerHold chained;
for (int i = 0; i < 6; i++) {
assert(!run(chained, 1, 50.0, 0.0, 0.0, 0.0, now));
now += 400'000'000ul;
}
PointerHold released;
assert(run(released, 3, 0.0, -25.0, 0.0, 0.0, now));
assert(!run(released, 1, 0.0, -25.0, 0.0, 0.0, now, false));
assert(!run(released, 2, 0.0, -25.0, 0.0, 0.0, now));
puts("ok");
return 0;
}
'''
@unittest.skipUnless(shutil.which("c++"), "needs a C++ compiler")
class PointerHoldTest(unittest.TestCase):
def test_only_a_game_that_holds_the_x_pointer_goes_relative(self):
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "hold.cpp"
binary = Path(tmp) / "hold"
source.write_text(PREAMBLE + rules() + MAIN)
subprocess.run(["c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", str(source), "-o", str(binary)], check=True)
self.assertEqual(subprocess.run([str(binary)], capture_output=True, text=True, check=True).stdout.strip(), "ok")
def test_a_hinted_constraint_starts_relative_and_others_wait_for_the_hold(self):
added = [line.strip() for line in added_source().splitlines()]
self.assertIn("update_pointer_hold( pPaintFocus, bHasPointerConstraint && bImageEmpty && nRelativeMouse != 2 && !bExcludedAppId,", added)
self.assertIn("pPaintFocus->cursor && pPaintFocus->cursor->IsConstrained() );", added)
self.assertIn("s_Hold.bHeld = bHinted;", added)
self.assertIn("if ( s_bTracking && bHinted && !s_bHinted )", added)
self.assertIn("const bool bGameHoldsPointer = bHasPointerConstraint && s_bPointerHeld;", added)
self.assertIn(": ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bGameHoldsPointer && !bExcludedAppId ) );", added)
def test_a_new_window_or_constraint_starts_its_own_hold(self):
tracker = block(added_source(), "static void update_pointer_hold")
reset = tracker.index("if ( pWindow != s_pWindow || pConstraint != s_pConstraint )")
self.assertIn("const steamcompmgr_win_t *pWindow = pFocus->inputFocusWindow;", tracker)
constraint = tracker.index("wlserver.GetCursorConstraint();")
self.assertLess(tracker.index("wlserver_lock();"), constraint)
self.assertLess(constraint, tracker.index("wlserver_unlock();"))
self.assertLess(reset, tracker.index("if ( s_bTracking && bHinted && !s_bHinted )"))
self.assertIn("s_bTracking = false;", tracker[reset:reset + 120])
def test_the_motion_sent_to_clients_is_counted(self):
added = [line.strip() for line in added_source().splitlines()]
self.assertIn("wlserver.flSentDX += unaccel_dx;", added)
self.assertIn("wlserver.flSentDY += unaccel_dy;", added)
def test_the_forced_and_follow_rules_are_unchanged(self):
removed = [line[1:].strip() for line in PATCH.read_text().splitlines() if line.startswith("-") and not line.startswith("---")]
self.assertEqual(removed, [": ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bHasPointerConstraint && !bExcludedAppId ) );"])
if __name__ == "__main__":
unittest.main()