diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-22 16:43:09 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-22 16:43:09 -0500 |
| commit | b37e4f8cb494ca14b9a00c967c7df8bd8d0a9ee1 (patch) | |
| tree | bf924a114fd1af1721ae86202f84d0f8f3a59341 | |
| parent | 104e640bbb60a49dc363ee1731478cd81deb1cc4 (diff) | |
| download | rulesets-b37e4f8cb494ca14b9a00c967c7df8bd8d0a9ee1.tar.gz rulesets-b37e4f8cb494ca14b9a00c967c7df8bd8d0a9ee1.zip | |
feat(scripts): add off-screen launch capture, layout/size, and tests to screenshot.py
Extends screenshot.py with --launch CMD, which runs a command on a transient headless Hyprland output, captures it, and tears the output down, so a UI can be verified without touching the visible workspace. --layout (tiled/monocle/floating) and --size control placement: output resolution for tiled/monocle, window size plus centering for floating.
Refactors the testable logic (size parsing, geometry strings, window matching, the exec-rule body, centering) into pure helpers and adds test_screenshot.py covering them across normal, boundary, and error cases. The grim/hyprctl wrappers and the capture orchestration stay thin and are verified functionally.
| -rwxr-xr-x | claude-templates/.ai/scripts/screenshot.py | 200 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_screenshot.py | 97 |
2 files changed, 259 insertions, 38 deletions
diff --git a/claude-templates/.ai/scripts/screenshot.py b/claude-templates/.ai/scripts/screenshot.py index 03e76ae..bdff419 100755 --- a/claude-templates/.ai/scripts/screenshot.py +++ b/claude-templates/.ai/scripts/screenshot.py @@ -2,28 +2,34 @@ """Capture a screenshot for visual verification (Wayland / Hyprland). Claude can read PNG files, so this turns "does the UI look right?" into an -inspectable artifact: capture the screen or one window, then read the path it -prints. +inspectable artifact: capture the screen, a window, or a freshly-launched app, +then read the path it prints. Modes (pick one): --full capture everything (all outputs) [default] --active capture the currently focused window --window REGEX capture the window whose class or title matches REGEX - (case-insensitive). Errors if zero or many match. --list list open windows (class / title / workspace) and exit + --launch CMD run CMD on a transient off-screen (headless) output, + capture it, and tear everything down — verifies an app's + UI without touching the visible workspace -Options: - --output PATH where to write the PNG - (default: /tmp/claude-screenshot-<timestamp>.png) - --delay SECONDS wait before capturing (let a window settle / animate) +Layout (with --launch): + --layout LAYOUT tiled (window fills output) | monocle (fills, gapless) | + floating (window floats, sized by --size, centered) + --size WxH tiled/monocle: headless output resolution; + floating: window size (e.g. 1600x1000) + --timeout SECONDS how long to wait for the launched window (default 10) -On success the saved path is printed on its own line; nothing else goes to -stdout, so a caller can capture it directly. +Common: + --output PATH PNG output path (default: timestamped file in /tmp); + the saved path is printed on stdout, parent dir created + --delay SECONDS wait before capturing (let a window settle / animate) -Requires grim and hyprctl. A window must be visible on the active workspace -for its region to capture meaningfully — grim reads the compositor's rendered -output, so a window on another workspace yields whatever is drawn at that -region, not the hidden window. +Requires grim and hyprctl. For window/region modes the window must be visible +on the active workspace — grim reads rendered outputs, so a hidden window +captures whatever is drawn at its region. The --launch mode sidesteps that by +rendering on a headless output that is real to the compositor but not displayed. """ import argparse @@ -35,13 +41,55 @@ import subprocess import sys import time from datetime import datetime +from typing import NoReturn -def die(msg): +def die(msg) -> NoReturn: print(f"screenshot: {msg}", file=sys.stderr) sys.exit(1) +# --------------------------- pure helpers (unit-tested) ---------------------- + +def parse_size(size): + """Parse 'WxH' into an (width, height) int tuple. Dies on malformed input.""" + m = re.fullmatch(r"(\d+)x(\d+)", size or "") + if not m: + die(f"--size expects WxH (e.g. 1600x1000), got {size!r}") + return int(m.group(1)), int(m.group(2)) + + +def geometry_str(client): + """Build a grim -g geometry string ('x,y wxh') from a client's at/size.""" + (x, y), (w, h) = client["at"], client["size"] + return f"{x},{y} {w}x{h}" + + +def match_windows(clients, regex): + """Return the CLIENTS whose class or title matches REGEX (case-insensitive).""" + pat = re.compile(regex, re.IGNORECASE) + return [c for c in clients + if pat.search(c.get("class", "") or "") + or pat.search(c.get("title", "") or "")] + + +def launch_rule(wsid, layout): + """Hyprland exec-rule body: place a window on WSID silently, per LAYOUT.""" + rule = f"workspace {wsid} silent" + if layout == "monocle": + rule += ";fullscreen 1" + elif layout == "floating": + rule += ";float" + return rule + + +def center_offset(ow, oh, w, h): + """Top-left offset to center a w*h window in an ow*oh output (clamped >=0).""" + return max(0, (ow - w) // 2), max(0, (oh - h) // 2) + + +# ------------------------------- I/O wrappers -------------------------------- + def require_tools(*tools): missing = [t for t in tools if shutil.which(t) is None] if missing: @@ -51,23 +99,32 @@ def require_tools(*tools): def hypr(*args): """Run `hyprctl -j <args>` and return parsed JSON.""" - out = subprocess.run(["hyprctl", "-j", *args], - capture_output=True, text=True) + out = subprocess.run(["hyprctl", "-j", *args], capture_output=True, text=True) if out.returncode != 0: die(f"hyprctl {' '.join(args)} failed: {out.stderr.strip()}") return json.loads(out.stdout) -def geometry_str(client): - """Build a grim -g geometry string from a Hyprland client's at/size.""" - (x, y), (w, h) = client["at"], client["size"] - return f"{x},{y} {w}x{h}" +def hypr_run(*args): + """Fire a hyprctl dispatch/keyword command (no JSON parse).""" + subprocess.run(["hyprctl", *args], capture_output=True, text=True) + + +def grim(output, geometry=None, output_name=None): + cmd = ["grim"] + if geometry: + cmd += ["-g", geometry] + if output_name: + cmd += ["-o", output_name] + cmd.append(output) + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + die(f"grim failed: {res.stderr.strip()}") def list_windows(): - clients = hypr("clients") - rows = sorted(clients, key=lambda c: (c.get("workspace", {}).get("id", 0), - c.get("class", ""))) + rows = sorted(hypr("clients"), + key=lambda c: (c.get("workspace", {}).get("id", 0), c.get("class", ""))) if not rows: print("(no open windows)") return @@ -77,10 +134,7 @@ def list_windows(): def find_window(regex): - pat = re.compile(regex, re.IGNORECASE) - matches = [c for c in hypr("clients") - if pat.search(c.get("class", "") or "") - or pat.search(c.get("title", "") or "")] + matches = match_windows(hypr("clients"), regex) if not matches: die(f"no window matches {regex!r} — run --list to see open windows") if len(matches) > 1: @@ -90,14 +144,71 @@ def find_window(regex): return matches[0] -def grim(output, geometry=None): - cmd = ["grim"] - if geometry: - cmd += ["-g", geometry] - cmd.append(output) - res = subprocess.run(cmd, capture_output=True, text=True) - if res.returncode != 0: - die(f"grim failed: {res.stderr.strip()}") +def capture_launched(cmd, out, settle, timeout, layout, size): + """Run CMD on a transient headless output, capture it, then tear down. + + Creates a virtual (headless) Hyprland output that the compositor renders but + does not display, launches CMD onto its workspace without switching the + visible view, applies LAYOUT, captures the output, and removes everything. + + LAYOUT: 'tiled'/'monocle' (window fills the output, sized by SIZE) or + 'floating' (window floats at SIZE, centered). SIZE is (w, h) or None. + """ + before_mons = {m["name"] for m in hypr("monitors")} + hypr_run("output", "create", "headless") + time.sleep(0.4) + new_mons = [m for m in hypr("monitors") if m["name"] not in before_mons] + if not new_mons: + die("could not create a headless output") + name = new_mons[0]["name"] + wsid = new_mons[0]["activeWorkspace"]["id"] + try: + if size and layout in ("tiled", "monocle"): + hypr_run("keyword", "monitor", f"{name},{size[0]}x{size[1]}@60,0x0,1") + time.sleep(0.2) + + before_wins = {c["address"] for c in hypr("clients")} + hypr_run("dispatch", "exec", f"[{launch_rule(wsid, layout)}] {cmd}") + + deadline = time.time() + timeout + new_wins = [] + while time.time() < deadline: + new_wins = [c for c in hypr("clients") if c["address"] not in before_wins] + if new_wins: + break + time.sleep(0.3) + if not new_wins: + die(f"no window appeared within {timeout}s of launching: {cmd}") + + # Daemon-spawned frames (emacsclient) ignore the exec rule, so apply + # placement and layout by address — robust for both spawn paths. + mon = next(m for m in hypr("monitors") if m["name"] == name) + ow, oh = mon["width"], mon["height"] + for c in new_wins: + addr = c["address"] + if c.get("workspace", {}).get("id") != wsid: + hypr_run("dispatch", "movetoworkspacesilent", f"{wsid},address:{addr}") + if layout == "floating": + hypr_run("dispatch", "setfloating", f"address:{addr}") + if size: + hypr_run("dispatch", "resizewindowpixel", + f"exact {size[0]} {size[1]},address:{addr}") + cx, cy = center_offset(ow, oh, size[0], size[1]) + hypr_run("dispatch", "movewindowpixel", f"exact {cx} {cy},address:{addr}") + + time.sleep(settle) + grim(out, output_name=name) + finally: + for c in hypr("clients"): + if c.get("workspace", {}).get("id") == wsid: + hypr_run("dispatch", "closewindow", f"address:{c['address']}") + hypr_run("output", "remove", name) + + +def resolve_output(path): + out = path or f"/tmp/claude-screenshot-{datetime.now():%Y%m%d-%H%M%S}.png" + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + return out def main(): @@ -108,6 +219,12 @@ def main(): mode.add_argument("--active", action="store_true", help="capture the focused window") mode.add_argument("--window", metavar="REGEX", help="capture window matching REGEX") mode.add_argument("--list", action="store_true", help="list windows and exit") + mode.add_argument("--launch", metavar="CMD", help="launch CMD off-screen and capture it") + p.add_argument("--layout", choices=("tiled", "monocle", "floating"), default="tiled", + help="window layout for --launch (default: tiled)") + p.add_argument("--size", help="WxH: output res (tiled/monocle) or window size (floating)") + p.add_argument("--timeout", type=float, default=10.0, + help="seconds to wait for the --launch window (default 10)") p.add_argument("--output", "-o", help="PNG output path") p.add_argument("--delay", type=float, default=0.0, help="seconds to wait before capture") args = p.parse_args() @@ -118,6 +235,15 @@ def main(): list_windows() return + size = parse_size(args.size) if args.size else None + + if args.launch: + out = resolve_output(args.output) + settle = args.delay if args.delay else 1.5 + capture_launched(args.launch, out, settle, args.timeout, args.layout, size) + print(out) + return + if args.delay: time.sleep(args.delay) @@ -131,9 +257,7 @@ def main(): geometry = geometry_str(win) # else: --full / default → whole screen - out = args.output or f"/tmp/claude-screenshot-{datetime.now():%Y%m%d-%H%M%S}.png" - parent = os.path.dirname(os.path.abspath(out)) - os.makedirs(parent, exist_ok=True) + out = resolve_output(args.output) grim(out, geometry) print(out) diff --git a/claude-templates/.ai/scripts/tests/test_screenshot.py b/claude-templates/.ai/scripts/tests/test_screenshot.py new file mode 100644 index 0000000..357caec --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_screenshot.py @@ -0,0 +1,97 @@ +"""Tests for screenshot.py — the pure helpers behind Wayland screenshot capture. + +The script is mostly thin wrappers around grim/hyprctl (I/O boundaries, verified +functionally). The logic worth testing is pure: size parsing, grim geometry +strings, window matching, the Hyprland exec-rule body, and floating-window +centering. Those are imported and exercised directly here; the subprocess +wrappers and the capture orchestration are not unit-tested. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import screenshot # noqa: E402 + + +# ------------------------------- parse_size ---------------------------------- + +def test_parse_size_normal(): + assert screenshot.parse_size("1600x1000") == (1600, 1000) + +def test_parse_size_boundary_zero_and_one(): + assert screenshot.parse_size("0x0") == (0, 0) + assert screenshot.parse_size("1x1") == (1, 1) + +@pytest.mark.parametrize("bad", ["", "bad", "100", "100x", "x100", "100X100", " 1x1"]) +def test_parse_size_error_malformed_dies(bad): + with pytest.raises(SystemExit): + screenshot.parse_size(bad) + + +# ------------------------------ geometry_str --------------------------------- + +def test_geometry_str_normal(): + assert screenshot.geometry_str({"at": [10, 20], "size": [800, 600]}) == "10,20 800x600" + +def test_geometry_str_boundary_origin_and_unit(): + assert screenshot.geometry_str({"at": [0, 0], "size": [1, 1]}) == "0,0 1x1" + + +# ------------------------------ match_windows -------------------------------- + +CLIENTS = [ + {"class": "Emacs", "title": "Emacs 30.2 : agent [.emacs.d]"}, + {"class": "firefox", "title": "TrueNAS — Mozilla Firefox"}, + {"class": "foot", "title": "foot"}, +] + +def test_match_windows_normal_by_class(): + assert screenshot.match_windows(CLIENTS, "firefox") == [CLIENTS[1]] + +def test_match_windows_normal_by_title(): + assert screenshot.match_windows(CLIENTS, "TrueNAS") == [CLIENTS[1]] + +def test_match_windows_case_insensitive(): + assert screenshot.match_windows(CLIENTS, "EMACS") == [CLIENTS[0]] + +def test_match_windows_multiple(): + # "o" appears in firefox and foot classes + assert len(screenshot.match_windows(CLIENTS, "foo")) == 1 + assert len(screenshot.match_windows(CLIENTS, "e")) >= 2 # Emacs + firefox (title/class) + +def test_match_windows_boundary_empty_clients(): + assert screenshot.match_windows([], "anything") == [] + +def test_match_windows_boundary_missing_fields(): + # client with no class/title keys must not raise + assert screenshot.match_windows([{"class": None, "title": None}], "x") == [] + +def test_match_windows_error_no_match(): + assert screenshot.match_windows(CLIENTS, "nonexistent-zzz") == [] + + +# ------------------------------- launch_rule --------------------------------- + +def test_launch_rule_tiled_default(): + assert screenshot.launch_rule(2, "tiled") == "workspace 2 silent" + +def test_launch_rule_monocle_adds_fullscreen(): + assert screenshot.launch_rule(2, "monocle") == "workspace 2 silent;fullscreen 1" + +def test_launch_rule_floating_adds_float(): + assert screenshot.launch_rule(7, "floating") == "workspace 7 silent;float" + + +# ------------------------------ center_offset -------------------------------- + +def test_center_offset_normal(): + assert screenshot.center_offset(1920, 1080, 800, 600) == (560, 240) + +def test_center_offset_boundary_exact_fit(): + assert screenshot.center_offset(1920, 1080, 1920, 1080) == (0, 0) + +def test_center_offset_error_window_larger_than_output_clamps(): + assert screenshot.center_offset(800, 600, 1000, 700) == (0, 0) |
