diff --git a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/fixture.py b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/fixture.py index 63b8a1869..d68760db3 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/fixture.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/computer/conformance/fixture.py @@ -1,9 +1,11 @@ -"""The Linux fixture the conformance probe stages with data/add and launches with computer-exec: a Tk window, the -Fixture Desk, whose widgets' screen boxes are written to /layout.json and every effect that reaches it, -with whatever lands in /inbox.txt, to /state.json, so the probe can aim at it and read back -what arrived. Run as ``python3 fixture.py ``; it needs a 1280x800 screen or larger.""" +"""The fixture the conformance probe stages with data/add and launches with computer-exec: a Tk window, the Fixture +Desk, whose widgets' screen boxes are written to /layout.json and every effect that reaches it, with +whatever lands in /inbox.txt, to /state.json, so the probe can aim at it and read back what +arrived. It records the same effects under X11, macOS's Aqua and Windows, whose Tk names keys, binds select-all and +reports the wheel differently. Run as ``python3 fixture.py ``; it needs a 1280x800 screen or larger.""" from __future__ import annotations +import ctypes import json import os import sys @@ -19,6 +21,14 @@ (20, 37, "#00ffff")) CLIPBOARD = "agentenv-probe clipboard" MIN_SIZE = (1280, 800) +#: X11's keysyms for the keys Aqua's Tk names by their character. +KEYSYMS = {",": "comma", " ": "space"} +#: A wheel notch's delta where Tk scales the wheel: Windows', and Tk 9's everywhere. +NOTCH_DELTA = 120 +#: Tk 9 packs a 's distances into its delta: y, a signed 16-bit number, in the low bits, x above. +Y_BITS = 16 +#: The pixels of a scrolled line, macOS's for a scroll posted in lines. +LINE_PX = 10 def main(state_dir: Path) -> None: @@ -32,7 +42,7 @@ def main(state_dir: Path) -> None: state: dict = {"name": "", "notes": "", "keys": [], "saved_count": 0, "subscribed": False, "selected": None, "scroll_top": 0.0, "cells": {}, "right_clicks": 0, "double_clicks": 0, "drags": [], "wheel": {"up": 0, "down": 0, "left": 0, "right": 0}, "clipboard": None, "loaded": None, - "screen": [width, height]} + "screen": [width, height], "windowing": root.tk.call("tk", "windowingsystem")} def save() -> None: part = state_dir / ".state.json.part" @@ -52,12 +62,20 @@ def save() -> None: def key(event) -> None: state["name"] = name.get() - state["keys"] = (state["keys"] + [event.keysym])[-100:] + state["keys"] = (state["keys"] + [KEYSYMS.get(event.keysym, event.keysym)])[-100:] state["clipboard"] = _clipboard(root) save() name.bind("", key) + def select_all(event) -> str: + name.selection_range(0, "end") + name.icursor("end") + return "break" + + # Tk binds Ctrl+/ to select-all on X11 alone; Aqua's is Cmd+A. + name.bind("", select_all) + def on_save() -> None: state["saved_count"] += 1 save() @@ -157,14 +175,25 @@ def release(event) -> None: wheel = tk.Canvas(root, width=200, height=120, bg="#e6f0ff", highlightthickness=0) wheel.place(x=840, y=500) - def wheeled(event, button: int) -> None: - # Tk 8.6 delivers X buttons 6 and 7 (horizontal) as 4 and 5 with Shift held. - horizontal = bool(event.state & 1) - state["wheel"][("left" if horizontal else "up") if button == 4 else ("right" if horizontal else "down")] += 1 + def wheeled(horizontal: bool, steps: int) -> None: + way = ("left" if horizontal else "up") if steps > 0 else ("right" if horizontal else "down") + state["wheel"][way] += abs(steps) save() - wheel.bind("", lambda e: wheeled(e, 4)) - wheel.bind("", lambda e: wheeled(e, 5)) + def scrolled(delta: int) -> None: + if steps := touchpad_steps(delta): + wheeled(*steps) + + # Tk 8.6 on X11 reports the wheel as buttons 4 and 5, Aqua, Windows and Tk 9 as , a horizontal scroll + # (X's buttons 6 and 7) as a vertical one with Shift held. Tk 9 reports a scroll in pixels, a trackpad's or one a + # driver posts as Aqua's do, as . + if root.tk.call("tk", "windowingsystem") == "x11" and tk.TkVersion < 8.7: + wheel.bind("", lambda e: wheeled(bool(e.state & 1), 1)) + wheel.bind("", lambda e: wheeled(bool(e.state & 1), -1)) + else: + wheel.bind("", lambda e: wheeled(bool(e.state & 1), wheel_steps(e.delta))) + if tk.TkVersion >= 8.7: + wheel.bind("", lambda e: scrolled(e.delta)) field = tk.Canvas(root, width=48, height=48, bg="#000000", highlightthickness=0, bd=0) field.place(x=1080, y=500) @@ -198,6 +227,22 @@ def write_layout() -> None: root.mainloop() +def wheel_steps(delta: int) -> int: + """The wheel steps a event's ``delta`` holds, up positive: whole notches of 120 where Tk scales them + so (Windows, and Tk 9 everywhere), else one a unit (Aqua's Tk 8.6).""" + return int(delta / NOTCH_DELTA) if abs(delta) >= NOTCH_DELTA else delta + + +def touchpad_steps(delta: int) -> tuple[bool, int] | None: + """The steps a event's ``delta`` holds, as horizontal or not and up or left positive, or None + when it moves nothing. A step is a line, LINE_PX, and macOS may coalesce several into one event.""" + dx, dy = delta >> Y_BITS, ctypes.c_int16(delta).value + if not (dx or dy): + return None + distance = dy or dx + return not dy, (1 if distance > 0 else -1) * max(1, round(abs(distance) / LINE_PX)) + + def _clipboard(root: tk.Tk) -> str | None: try: return root.clipboard_get() diff --git a/packages/agentenv-protocol/tests/test_computer_conformance.py b/packages/agentenv-protocol/tests/test_computer_conformance.py index d4ff28957..a7dcfe8fe 100644 --- a/packages/agentenv-protocol/tests/test_computer_conformance.py +++ b/packages/agentenv-protocol/tests/test_computer_conformance.py @@ -4,8 +4,10 @@ fronts that exports is asked for its export once; the report's verdicts and the CLI.""" from __future__ import annotations +import importlib import json -from types import SimpleNamespace +import sys +from types import ModuleType, SimpleNamespace import pytest @@ -284,6 +286,29 @@ async def test_a_fixture_that_doesnt_start_leaves_full_unverified_and_is_cleaned assert (["kill", "4243"] in probed.commands) is (driver is SilentFixture) +@pytest.mark.parametrize("delta, steps", [(1, 1), (-3, -3), (120, 1), (-360, -3), (240, 2)], + ids=["aqua-up", "aqua-down-3", "scaled-up", "scaled-down-3", "scaled-up-2"]) +def test_the_fixture_counts_a_wheel_events_steps_whether_tk_scales_them_by_120_or_not(delta, steps, monkeypatch): + """Aqua's Tk 8.6 reports a step as a delta of 1; Windows, and Tk 9 everywhere, as 120.""" + assert _fixture(monkeypatch).wheel_steps(delta) == steps + + +@pytest.mark.parametrize("delta, steps", [(10, (False, 1)), (65526, (False, -1)), (65516, (False, -2)), + (655360, (True, 1)), (-655360, (True, -1)), (0, None)], + ids=["up", "down", "down-2-coalesced", "left", "right", "still"]) +def test_the_fixture_counts_a_scroll_tk_9_reports_in_pixels_by_the_lines_it_moves(delta, steps, monkeypatch): + """The deltas Tk 9 on macOS gave cua-driver's scrolls: 10 pixels a line, two lines at once where macOS coalesced + them.""" + assert _fixture(monkeypatch).touchpad_steps(delta) == steps + + +def _fixture(monkeypatch): + """The fixture module, which runs in the computer, read with Tk stood in for, as this Python may have none.""" + monkeypatch.setitem(sys.modules, "tkinter", ModuleType("tkinter")) + monkeypatch.delitem(sys.modules, "agentenv_protocol.computer.conformance.fixture", raising=False) + return importlib.import_module("agentenv_protocol.computer.conformance.fixture") + + def test_a_refusal_must_carry_the_code_and_applied_no(): def result(error: bool, **data) -> CallToolResult: return CallToolResult(content=[TextContent(type="text", text="x")], structuredContent=data, isError=error)