LazyBoy2/box/box-a11y

259 lines
9.4 KiB
Plaintext
Raw Normal View History

2026-09-16 14:00:19 +00:00
#!/usr/bin/env python3
"""Accessibility snapshot of the box desktop (AT-SPI).
Prints one line per interactive/visible element with its role, name and the
screen-pixel centre the computer tool can click:
[12] button "登入" @ (640,412) 96x36 win="蝦皮購物 - Chromium"
Usage: box-a11y [--json] [--limit N] [--budget SECONDS]
Exit 0 with an empty list when nothing is exposed; exit 2 when AT-SPI itself is
unreachable (the desktop or registry is not up).
"""
import argparse
import json
import os
import sys
import time
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
# Always listed when on screen, even without a name (the agent can still
# target them by position/value).
INTERACTIVE = {
"push button", "toggle button", "radio button", "check box", "link",
"menu item", "check menu item", "radio menu item", "tab", "page tab",
"list item", "tree item", "combo box", "spin button", "slider",
"entry", "password text",
}
# Listed only when they carry a name or text (headings, labels, cells...).
NAMED = {
"heading", "label", "static", "text", "image", "table cell",
"column header", "row header", "icon", "menu", "tool tip", "alert",
"dialog", "frame",
}
ROLE_SHORT = {
"push button": "button", "toggle button": "toggle", "radio button": "radio",
"check box": "checkbox", "menu item": "menuitem", "check menu item": "menuitem",
"radio menu item": "menuitem", "page tab": "tab", "list item": "listitem",
"tree item": "treeitem", "combo box": "combobox", "spin button": "spinner",
"password text": "password", "entry": "textbox", "text": "text",
"static": "text", "document frame": "document", "table cell": "cell",
"column header": "header", "row header": "header", "internal frame": "frame",
}
def load_dbus_env():
if os.environ.get("DBUS_SESSION_BUS_ADDRESS"):
return
try:
with open("/tmp/lazyboy/dbus.env", encoding="utf-8") as fh:
for line in fh:
key, _, value = line.strip().partition("=")
if key == "DBUS_SESSION_BUS_ADDRESS" and value:
os.environ[key] = value
except OSError:
pass
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true")
ap.add_argument("--limit", type=int, default=300)
ap.add_argument("--budget", type=float, default=6.0)
ap.add_argument("--max-depth", type=int, default=40)
args = ap.parse_args()
load_dbus_env()
os.environ.setdefault("DISPLAY", ":1")
try:
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
except Exception as exc: # noqa: BLE001
print(f"AT-SPI bindings unavailable: {exc}", file=sys.stderr)
return 2
try:
Atspi.init()
desktop = Atspi.get_desktop(0)
app_count = desktop.get_child_count()
except Exception as exc: # noqa: BLE001
print(f"AT-SPI registry unreachable: {exc}", file=sys.stderr)
return 2
screen_w = int(os.environ.get("LAZYBOY_SCREEN_W", "0") or 0)
screen_h = int(os.environ.get("LAZYBOY_SCREEN_H", "0") or 0)
deadline = time.monotonic() + args.budget
out = []
truncated = False
seen_apps = []
def showing(node):
try:
st = node.get_state_set()
return st.contains(Atspi.StateType.SHOWING) and st.contains(Atspi.StateType.VISIBLE)
except Exception: # noqa: BLE001
return False
def extents(node):
try:
comp = node.get_component() if hasattr(node, "get_component") else node.get_component_iface()
if comp is None:
return None
r = comp.get_extents(Atspi.CoordType.SCREEN)
return int(r.x), int(r.y), int(r.width), int(r.height)
except Exception: # noqa: BLE001
return None
def onscreen(box):
x, y, w, h = box
if w <= 0 or h <= 0:
return False
if screen_w and (x >= screen_w or x + w <= 0):
return False
if screen_h and (y >= screen_h or y + h <= 0):
return False
return True
def walk(node, depth, win_title):
nonlocal truncated
if truncated or time.monotonic() > deadline:
truncated = True
return
if depth > args.max_depth or len(out) >= args.limit:
truncated = truncated or len(out) >= args.limit
return
try:
role = node.get_role_name()
except Exception: # noqa: BLE001
return
if depth > 1 and not showing(node):
return
box = extents(node)
if depth > 1 and (box is None or not onscreen(box)):
return
name = ""
try:
name = (node.get_name() or "").strip()
except Exception: # noqa: BLE001
pass
if role in ("frame", "dialog", "window") and name:
win_title = name
try:
st = node.get_state_set()
focused = st.contains(Atspi.StateType.FOCUSED)
editable = st.contains(Atspi.StateType.EDITABLE)
checked = st.contains(Atspi.StateType.CHECKED)
selected = st.contains(Atspi.StateType.SELECTED)
disabled = not st.contains(Atspi.StateType.ENABLED)
except Exception: # noqa: BLE001
focused = editable = checked = selected = disabled = False
value = ""
if role in ("entry", "password text", "text", "spin button", "slider") or editable:
try:
txt = node.get_text() if hasattr(node, "get_text") else node.get_text_iface()
if txt is not None and role != "password text":
value = (txt.get_text(0, min(txt.get_character_count(), 80)) or "").strip()
except Exception: # noqa: BLE001
pass
printable = depth > 1 and box is not None and (
role in INTERACTIVE
or (role in NAMED and (name or value))
or (editable and role == "text")
)
if printable:
x, y, w, h = box
out.append({
"role": ROLE_SHORT.get(role, role.replace(" ", "")),
"name": name[:120],
"value": value[:80],
"x": x, "y": y, "w": w, "h": h,
"cx": x + w // 2, "cy": y + h // 2,
"focused": focused, "checked": checked, "selected": selected,
"disabled": disabled, "editable": editable,
"window": win_title,
})
try:
count = node.get_child_count()
except Exception: # noqa: BLE001
return
for i in range(min(count, 2000)):
if truncated:
return
try:
child = node.get_child_at_index(i)
except Exception: # noqa: BLE001
continue
if child is None:
continue
walk(child, depth + 1, win_title)
for i in range(app_count):
try:
app = desktop.get_child_at_index(i)
if app is None:
continue
app_name = app.get_name() or ""
except Exception: # noqa: BLE001
continue
if app_name in ("xfce4-panel", "wrapper-2.0", "xfdesktop", "xfwm4", "box-a11y", "python3"):
# The desktop shell is visible in the screenshot already; keep the
# list for the windows the agent actually works in.
continue
seen_apps.append(app_name)
walk(app, 0, app_name)
# Chromium mirrors some nodes (omnibox rows, nested list items); one entry
# per role/name/box is enough for targeting. Then top-to-bottom,
# left-to-right reading order.
unique = {}
for e in out:
unique.setdefault((e["role"], e["name"], e["cx"], e["cy"], e["w"], e["h"]), e)
window_order = {}
for e in unique.values():
window_order.setdefault(e["window"], len(window_order))
out = sorted(unique.values(), key=lambda e: (window_order[e["window"]], e["cy"] // 12, e["cx"]))
for i, e in enumerate(out):
e["id"] = i
if args.json:
json.dump({"apps": seen_apps, "elements": out, "truncated": truncated}, sys.stdout, ensure_ascii=False)
print()
return 0
lines = []
current_window = None
for e in out:
if len(window_order) > 1 and e["window"] != current_window:
current_window = e["window"]
lines.append(f"## {current_window}")
flags = []
if e["focused"]:
flags.append("focused")
if e["checked"]:
flags.append("checked")
if e["selected"]:
flags.append("selected")
if e["disabled"]:
flags.append("disabled")
label = f'"{e["name"]}"' if e["name"] else ""
if e["value"]:
label += f' value="{e["value"]}"'
parts = [f"[{e['id']}]", e["role"]]
if label:
parts.append(label)
parts.append(f"@ ({e['cx']},{e['cy']}) {e['w']}x{e['h']}")
if flags:
parts.append("(" + ", ".join(flags) + ")")
lines.append(" ".join(parts))
if not lines:
lines.append("(no accessible elements exposed; apps: " + ", ".join(seen_apps) + ")")
if truncated:
lines.append(f"... truncated at {len(out)} elements; scroll or focus a window and snapshot again")
print("\n".join(lines))
return 0
if __name__ == "__main__":
sys.exit(main())