Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <state-dir>/layout.json and every effect that reaches it,
with whatever lands in <state-dir>/inbox.txt, to <state-dir>/state.json, so the probe can aim at it and read back
what arrived. Run as ``python3 fixture.py <state-dir>``; 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 <state-dir>/layout.json and every effect that reaches it, with
whatever lands in <state-dir>/inbox.txt, to <state-dir>/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 <state-dir>``; it needs a 1280x800 screen or larger."""
from __future__ import annotations

import ctypes
import json
import os
import sys
Expand All @@ -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 <MouseWheel> delta where Tk scales the wheel: Windows', and Tk 9's everywhere.
NOTCH_DELTA = 120
#: Tk 9 packs a <TouchpadScroll>'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:
Expand All @@ -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"
Expand All @@ -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("<KeyRelease>", 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("<Control-slash>", select_all)

def on_save() -> None:
state["saved_count"] += 1
save()
Expand Down Expand Up @@ -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("<Button-4>", lambda e: wheeled(e, 4))
wheel.bind("<Button-5>", 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 <MouseWheel>, 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 <TouchpadScroll>.
if root.tk.call("tk", "windowingsystem") == "x11" and tk.TkVersion < 8.7:
wheel.bind("<Button-4>", lambda e: wheeled(bool(e.state & 1), 1))
wheel.bind("<Button-5>", lambda e: wheeled(bool(e.state & 1), -1))
else:
wheel.bind("<MouseWheel>", lambda e: wheeled(bool(e.state & 1), wheel_steps(e.delta)))
if tk.TkVersion >= 8.7:
wheel.bind("<TouchpadScroll>", 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)
Expand Down Expand Up @@ -198,6 +227,22 @@ def write_layout() -> None:
root.mainloop()


def wheel_steps(delta: int) -> int:
"""The wheel steps a <MouseWheel> 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 <TouchpadScroll> 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()
Expand Down
27 changes: 26 additions & 1 deletion packages/agentenv-protocol/tests/test_computer_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading