aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xclaude-templates/.ai/scripts/screenshot.py200
-rw-r--r--claude-templates/.ai/scripts/tests/test_screenshot.py97
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)