From be2e770e66616f3b60d564774ce92248867b2824 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:40:00 +1000 Subject: [PATCH] Control: stale clears, pauses reconverge, pastes split per request - The Frame says it has caught up as soon as an aimed event lands after a stale one, so the page stops re-reading the panels. - After a device pause it lets go of everything it holds (releases that arrived while paused were dropped), and waits for the device once per batch, not once per event. - The quick focus check no longer freshens the panel geometry's age. - Each request carries at most about 100 characters of text. - Turning Control off while it connects doesn't report an error. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/page/ctrl_gestures.mjs | 10 ++++++++++ tests/test_touch.py | 35 +++++++++++++++++++++++++++++++++++ ui/frame_touch.py | 17 ++++++++++++++++- ui/index.html | 10 +++++++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/tests/page/ctrl_gestures.mjs b/tests/page/ctrl_gestures.mjs index 43bb9b2..871b8b8 100644 --- a/tests/page/ctrl_gestures.mjs +++ b/tests/page/ctrl_gestures.mjs @@ -231,4 +231,14 @@ function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360 if (calls !== 1) fail("new input bypassed the backoff: " + calls + " requests"); clearTimeout(p.ctrl.retry); } +// A long paste goes in several requests, so a release never waits behind all of it. +{ + const batches = []; + const p = page({ api: async (path, body) => { batches.push(body.events); return { state: "ready", sent: true }; } }); + p.ctrlText("y".repeat(450)); + p.ctrlButton("left", false); + await tick(30); + if (!batches.every(b => b.reduce((a, e) => a + (e.text?.length || 0), 0) <= 100)) fail("a batch carried too much text"); + if (batches.length < 5) fail("paste went in " + batches.length + " requests"); +} console.log("control gestures ok"); diff --git a/tests/test_touch.py b/tests/test_touch.py index b6edacf..a257152 100644 --- a/tests/test_touch.py +++ b/tests/test_touch.py @@ -90,6 +90,7 @@ class Apply(unittest.TestCase): def setUp(self): self.focused = {"window": 7, "display": ":1", "root": [1280, 720], "width": 1280, "height": 720, "name": "x"} saved = self.t.focus, self.t.say, self.t.focus_now + self.t.STALE[0] = False self.said = [] self.t.focus = lambda: dict(self.focused) self.t.focus_now = lambda: (self.focused["window"], self.focused["display"]) @@ -129,6 +130,14 @@ class Apply(unittest.TestCase): self.assertEqual(len(calls), 2) self.assertEqual([c[0] for c in gs.calls], ["move_to", "move_to", "button"]) + def test_stale_is_said_once_and_cleared(self): + gs, panel = FakeGamescope(), None + for e in ({"fx": 0.5, "fy": 0.5, "window": 99, "display": ":1"}, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":1"}, + {"fx": 0.6, "fy": 0.5, "window": 7, "display": ":1"}): + panel = self.t.apply(gs, e, panel) + self.assertEqual(self.said, [("ready", {"focus": 7, "display": ":1", "stale": True}), + ("ready", {"focus": 7, "display": ":1"})]) + def test_same_window_id_on_the_other_display_is_another_panel(self): gs = FakeGamescope() self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None) @@ -159,6 +168,32 @@ class Apply(unittest.TestCase): self.assertEqual(g.log[-2:], [("button", 0x110, False), ("key", 42, False)]) self.assertEqual((g.held, g.keys), (set(), set())) + def test_resuming_after_a_pause_lets_go_of_everything(self): + t = self.t + log, queue = [], [1] + + class L: + def __getattr__(self, name): + if name == "ei_get_event": + return lambda ei: queue.pop(0) if queue else None + return {"ei_event_get_type": lambda ev: t.EV_DEVICE_RESUMED, "ei_event_get_device": lambda ev: "dev", + "ei_device_has_capability": lambda d, c: True, + "ei_device_button_button": lambda d, c, down: log.append(("button", c, down)), + "ei_device_keyboard_key": lambda d, c, down: log.append(("key", c, down))}.get(name, lambda *a: 0) + + g = t.Gamescope.__new__(t.Gamescope) + g.L, g.ei, g.fd, g.device, g.sequence, g.alive = L(), None, None, None, 0, True + g.held, g.keys = {0x110}, {42} + g.frame = lambda: None + old = t.select.select + t.select.select = lambda *a: ([], [], []) + try: + g.pump() + finally: + t.select.select = old + self.assertEqual(sorted(log), [("button", 0x110, False), ("key", 42, False)]) + self.assertEqual(g.device, "dev") + def test_text_uses_shift_for_capitals(self): class Keys(self.t.Gamescope): def __init__(self): diff --git a/ui/frame_touch.py b/ui/frame_touch.py index 9643988..75f9c63 100644 --- a/ui/frame_touch.py +++ b/ui/frame_touch.py @@ -251,6 +251,10 @@ class Gamescope: self.sequence += 1 self.L.ei_device_start_emulating(device, self.sequence) self.device = device + # Releases that arrived while it was paused were dropped: let go of + # everything now, so the headset and this agent agree nothing is held. + if self.held or self.keys: + self.release_all() elif kind in (EV_DEVICE_PAUSED, EV_DEVICE_REMOVED): if self.L.ei_event_get_device(ev) == self.device: self.device = None @@ -335,6 +339,9 @@ def number(value, limit=100000.0): return max(-limit, min(limit, float(value))) +STALE = [False] # whether the last status said a tap went nowhere + + def aimed_elsewhere(event, panel): """Whether an event names a panel that isn't the one with focus now.""" if "window" not in event: @@ -354,13 +361,17 @@ def apply(gs, event, panel): acts = any(k in event for k in ("button", "key", "text", "scroll")) and event.get("down") is not False if "window" in event: if panel and acts and focus_now() == (panel.get("window"), panel.get("display")): - panel = {**panel, "_at": time.time()} # still the same panel: keep its geometry + pass # still the same panel (its geometry is re-read on the usual one-second schedule) elif acts or not panel or time.time() - panel.get("_at", 0) > 1 or aimed_elsewhere(event, panel): panel = {**focus(), "_at": time.time()} stale = aimed_elsewhere(event, panel) if "window" in event else False if stale and not (event.get("down") is False and ("button" in event or "key" in event)): say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs + STALE[0] = True return panel + if "window" in event and STALE[0]: + STALE[0] = False + say("ready", focus=panel.get("window"), display=panel.get("display")) # caught up: stop re-syncing if "fx" in event and panel and panel.get("window"): gs.move_to(*to_root(panel, number(event["fx"], 1), number(event["fy"], 1))) if "dx" in event or "dy" in event: @@ -404,8 +415,12 @@ def main(): return 0 # the server went away *lines, pending = (pending + chunk).split(b"\n") for line in lines: + waited = False for event in events(line): + if gs.device is None and waited: + continue # still paused: don't wait again for each event of this batch if gs.device is None: + waited = True # Paused (gamescope can pause the device): wait a moment; drop this # event if it doesn't come back. Only a disconnect ends the session. try: diff --git a/ui/index.html b/ui/index.html index 0414a3d..038562f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1507,6 +1507,7 @@ async function ctrlPoll(n) { const r = await api(n ? "/api/touch" : "/api/touch?start=1"); ctrl.state = r.state; ctrl.message = r.message || ""; } catch (e) { ctrl.state = "error"; ctrl.message = e.message; } + if (!ctrl.on) return; // turned off while connecting ctrlShow(); if (ctrl.state !== "ready" && ctrl.state !== "error" && n < 30) return setTimeout(() => ctrlPoll(n + 1), 500); if (ctrl.state === "ready") return ctrlFlush(); @@ -1560,7 +1561,14 @@ async function ctrlFlush() { return; } ctrl.sending = true; - const batch = ctrl.queue.splice(0, 200); + // Up to 200 events, and at most about 100 characters of text: the Frame types a batch + // key by key before reading the next, and a release mustn't wait behind a long paste. + let n = 0, chars = 0; + while (n < ctrl.queue.length && n < 200 && (n === 0 || chars + (ctrl.queue[n].text?.length || 0) <= 100)) { + chars += ctrl.queue[n].text?.length || 0; + n++; + } + const batch = ctrl.queue.splice(0, n); let again = 0; try { const r = await api("/api/touch", { events: batch });