shotbox 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shotbox/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """shotbox, from Python: `Session` starts a sealed session, `here()` is the
2
+ display of the `shotbox run` a script is inside, and both are a `Screen`, to
3
+ wait on, drive and take pictures of. See shotbox/screen.py."""
4
+
5
+ __version__ = "0.3.0"
6
+
7
+ from .screen import Screen, SessionError, here # noqa: E402
8
+ from .session import Session # noqa: E402
9
+
10
+ __all__ = ["Screen", "Session", "SessionError", "here", "__version__"]
shotbox/__main__.py ADDED
@@ -0,0 +1,390 @@
1
+ """shotbox: reproducible screenshots of real programs, in a sealed session.
2
+
3
+ shotbox shoot OUT.png [options] -- COMMAND... run it, wait, take the picture
4
+ shotbox run [options] -- COMMAND... run it in a sealed session
5
+ shotbox term [options] -- COMMAND... a terminal to take pictures of
6
+ shotbox wait window|port|file|ready|stable ARG (inside a session) wait for it
7
+ shotbox capture OUT.png [--window RE] (inside a session) take a picture
8
+ shotbox key CHORD... (inside a session) press keys
9
+ shotbox type TEXT (inside a session) type text
10
+ shotbox click X Y [--window RE] (inside a session) click there
11
+ shotbox move X Y [--window RE] (inside a session) point there
12
+ shotbox drag X1 Y1 X2 Y2 [--window RE] (inside a session) drag
13
+ shotbox park (inside a session) pointer out of the way
14
+ shotbox compare A.png B.png [--diff D.png] how many pixels differ
15
+ shotbox compare REFDIR NEWDIR [NAME...] the same, picture by picture
16
+
17
+ `shotbox COMMAND --help` for each one's options.
18
+ """
19
+
20
+ import argparse
21
+ import os
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ from . import term, x, xtest
27
+ from .screen import Screen, SessionError
28
+ from .session import Session
29
+
30
+
31
+ def size(s):
32
+ w, h = s.lower().split("x")
33
+ return int(w), int(h)
34
+
35
+
36
+ def session_options(p):
37
+ g = p.add_argument_group("the session")
38
+ g.add_argument("--screen", type=size, default=(1280, 800), metavar="WxH",
39
+ help="the display's size (1280x800)")
40
+ g.add_argument("--desktop", action="store_true",
41
+ help="real GSettings (dconf) and the system's D-Bus services, "
42
+ "for desktop shells; the default is in-memory settings "
43
+ "and nothing activatable")
44
+ g.add_argument("--system-bus", action="store_true", help="a stand-in system bus too")
45
+ g.add_argument("--seed", metavar="DIR", help="start the scratch home as a copy of DIR")
46
+ g.add_argument("--env", action="append", default=[], metavar="NAME=VALUE",
47
+ help="set a variable inside (repeatable)")
48
+ g.add_argument("--pass", dest="passthrough", action="append", default=[], metavar="NAME",
49
+ help="let a variable through from outside (repeatable)")
50
+ g.add_argument("--keep", action="store_true", help="keep the scratch dir, and say where")
51
+ g.add_argument("--wayland", action="store_true",
52
+ help="a Wayland session, under a headless sway, rather than X11 on "
53
+ "Xvfb (docs/wayland.md)")
54
+ g.add_argument("--xwayland", action="store_true",
55
+ help="a Wayland session with Xwayland in it too, for X clients")
56
+
57
+
58
+ def make_session(a):
59
+ env = dict(e.split("=", 1) for e in a.env)
60
+ return Session(size=a.screen, desktop=a.desktop, system_bus=a.system_bus,
61
+ seed=a.seed, keep=a.keep, env=env, passthrough=a.passthrough,
62
+ wayland=a.wayland, xwayland=a.xwayland)
63
+
64
+
65
+ def self_command():
66
+ """How to run this shotbox again, from inside a session: this checkout's
67
+ bin/shotbox, or else this Python's shotbox module, as installed."""
68
+ here = Path(__file__).resolve().parent.parent / "bin" / "shotbox"
69
+ return [sys.executable, str(here)] if here.exists() else [sys.executable, "-m", "shotbox"]
70
+
71
+
72
+ def split(argv):
73
+ """Options, and the command after --."""
74
+ if "--" in argv:
75
+ i = argv.index("--")
76
+ return argv[:i], argv[i + 1:]
77
+ return argv, []
78
+
79
+
80
+ def command(cmd, p):
81
+ if not cmd:
82
+ p.error("no command given, after --")
83
+ return cmd
84
+
85
+
86
+ def cmd_run(argv):
87
+ p = argparse.ArgumentParser(prog="shotbox run",
88
+ description="Run a command in a sealed session: its own X display, "
89
+ "D-Bus and home, and none of yours.")
90
+ session_options(p)
91
+ opts, cmd = split(argv)
92
+ a = p.parse_args(opts)
93
+ cmd = command(cmd, p)
94
+ with make_session(a) as s:
95
+ return s.run(cmd)
96
+
97
+
98
+ def tail(path, n=30):
99
+ try:
100
+ lines = Path(path).read_text(errors="replace").splitlines()
101
+ except OSError:
102
+ return "(no output)"
103
+ return "\n".join(lines[-n:]) or "(no output)"
104
+
105
+
106
+ def cmd_shoot(argv):
107
+ p = argparse.ArgumentParser(
108
+ prog="shotbox shoot",
109
+ description="Start a command in a sealed session, wait until it's ready, "
110
+ "take a screenshot, and stop it.")
111
+ p.add_argument("out", help="the PNG to write")
112
+ p.add_argument("--window", metavar="RE",
113
+ help="take this window (its title, a regex matched in full, "
114
+ "or app=RE for its app id) "
115
+ "instead of the whole display; also waits for it")
116
+ p.add_argument("--crop", metavar="WxH+X+Y",
117
+ help="then crop to this; without it, to what the program "
118
+ "wrote to $SHOTBOX_SCRATCH/crop, if it did (as "
119
+ "`shotbox term --crop` does)")
120
+ p.add_argument("--wait", action="append", default=[], metavar="WHAT",
121
+ help="wait for window:RE, port:N, file:PATH, ready (the "
122
+ "program touched $SHOTBOX_SCRATCH/ready, as `shotbox "
123
+ "term` does after its steps), or stable[:SECS] (the "
124
+ "--window, or the display, unchanged for SECS, 0.5); "
125
+ "repeatable, in order")
126
+ p.add_argument("--settle", type=float, default=0.3, metavar="SECS",
127
+ help="then wait this long for the last paint (0.3)")
128
+ p.add_argument("--park", action="store_true",
129
+ help="move the pointer out of the way before the picture")
130
+ p.add_argument("--timeout", type=float, default=30, metavar="SECS",
131
+ help="give up on a wait after this long (30)")
132
+ p.add_argument("--log", metavar="FILE", help="keep the command's output here")
133
+ p.add_argument("--failed", metavar="FILE",
134
+ help="if it fails, a picture of the screen goes here "
135
+ "(OUT's name with -failed, beside it)")
136
+ session_options(p)
137
+ opts, cmd = split(argv)
138
+ a = p.parse_args(opts)
139
+ cmd = command(cmd, p)
140
+ waits = list(a.wait)
141
+ if a.window and not any(w.startswith("window:") for w in waits):
142
+ waits.insert(0, "window:" + a.window)
143
+ if not waits and not a.settle:
144
+ p.error("nothing to wait for: give --window, --wait or --settle")
145
+
146
+ with make_session(a) as s:
147
+ s.failed = a.failed or str(Path(a.out).with_name(Path(a.out).stem + "-failed.png"))
148
+ log = a.log or str(s.scratch / "command.log")
149
+ proc = s.spawn(cmd, log=log)
150
+ try:
151
+ for spec in waits:
152
+ kind, _, arg = spec.partition(":")
153
+ s.until(*s.test(kind, arg, window=a.window), a.timeout, alive=proc)
154
+ time.sleep(a.settle)
155
+ if proc.poll() is not None:
156
+ s.fail(f"the program exited (status {proc.returncode}) "
157
+ "before the picture was taken")
158
+ if a.window and not s.backend.find_window(s.env, a.window):
159
+ s.fail(f"the window {a.window!r} went away")
160
+ crop = a.crop
161
+ if not crop and (s.scratch / "crop").exists():
162
+ crop = (s.scratch / "crop").read_text().strip()
163
+ s.capture(a.out, window=a.window, crop=crop, park=a.park)
164
+ except SessionError as e:
165
+ sys.stderr.write(f"shotbox: {e}\n--- {' '.join(cmd)} said ---\n{tail(log)}\n")
166
+ return 1
167
+ print(a.out)
168
+ return 0
169
+
170
+
171
+ def inside():
172
+ """The display of the session this runs in."""
173
+ if not os.environ.get("SHOTBOX_SCRATCH"):
174
+ raise SystemExit("shotbox: this only works inside `shotbox run`")
175
+ return Screen()
176
+
177
+
178
+ def cmd_wait(argv):
179
+ p = argparse.ArgumentParser(prog="shotbox wait",
180
+ description="Inside `shotbox run`: wait for a window, "
181
+ "a port, a file, the ready file, or the "
182
+ "screen to stop changing.")
183
+ p.add_argument("kind", choices=("window", "port", "file", "ready", "stable"))
184
+ p.add_argument("arg", nargs="?", default="",
185
+ help="the window's title (a regex; app=RE for its app id), the "
186
+ "port, the file; for "
187
+ "stable, how long it must hold still (0.5)")
188
+ p.add_argument("--window", metavar="RE",
189
+ help="for stable: watch this window rather than the display")
190
+ p.add_argument("--timeout", type=float, default=30)
191
+ p.add_argument("--failed", metavar="FILE",
192
+ help="if it gives up, a picture of the screen goes here "
193
+ "($SHOTBOX_FAILED)")
194
+ a = p.parse_args(argv)
195
+ screen = inside()
196
+ screen.failed = a.failed or screen.failed
197
+ screen.until(*screen.test(a.kind, a.arg, window=a.window), a.timeout)
198
+ return 0
199
+
200
+
201
+ def cmd_capture(argv):
202
+ p = argparse.ArgumentParser(prog="shotbox capture",
203
+ description="Inside `shotbox run`: screenshot the display "
204
+ "or one window.")
205
+ p.add_argument("out")
206
+ p.add_argument("--window", metavar="RE")
207
+ p.add_argument("--crop", metavar="WxH+X+Y")
208
+ p.add_argument("--park", action="store_true",
209
+ help="move the pointer out of the way first, as `shotbox park`")
210
+ a = p.parse_args(argv)
211
+ inside().capture(a.out, window=a.window, crop=a.crop, park=a.park)
212
+ return 0
213
+
214
+
215
+ def cmd_key(argv):
216
+ p = argparse.ArgumentParser(prog="shotbox key",
217
+ description="Inside `shotbox run`: press and release each "
218
+ "chord in turn, e.g. ctrl+comma, Return, "
219
+ "alt+shift+Tab.")
220
+ p.add_argument("chords", nargs="+", metavar="CHORD")
221
+ a = p.parse_args(argv)
222
+ inside().key(*a.chords)
223
+ return 0
224
+
225
+
226
+ def cmd_type(argv):
227
+ p = argparse.ArgumentParser(prog="shotbox type",
228
+ description="Inside `shotbox run`: type text into whatever "
229
+ "has the keyboard focus.")
230
+ p.add_argument("text")
231
+ a = p.parse_args(argv)
232
+ inside().type(a.text)
233
+ return 0
234
+
235
+
236
+ def pointer_command(name, what):
237
+ def cmd(argv):
238
+ p = argparse.ArgumentParser(prog=f"shotbox {name}",
239
+ description=f"Inside `shotbox run`: {what}.")
240
+ p.add_argument("x", type=int)
241
+ p.add_argument("y", type=int)
242
+ p.add_argument("--window", metavar="RE",
243
+ help="X and Y are inside this window rather than the screen")
244
+ if name == "click":
245
+ p.add_argument("--button", type=int, default=1, help="1 left, 2 middle, 3 right")
246
+ p.add_argument("--double", action="store_true", help="click twice")
247
+ a = p.parse_args(argv)
248
+ if name == "click":
249
+ inside().click(a.x, a.y, window=a.window, button=a.button, double=a.double)
250
+ else:
251
+ inside().move(a.x, a.y, window=a.window)
252
+ return 0
253
+ return cmd
254
+
255
+
256
+ def cmd_drag(argv):
257
+ p = argparse.ArgumentParser(prog="shotbox drag",
258
+ description="Inside `shotbox run`: press at X1 Y1, move to "
259
+ "X2 Y2, release: a divider, a slider, a drop.")
260
+ for n in ("x1", "y1", "x2", "y2"):
261
+ p.add_argument(n, type=int)
262
+ p.add_argument("--window", metavar="RE",
263
+ help="the points are inside this window rather than the screen")
264
+ p.add_argument("--button", type=int, default=1)
265
+ a = p.parse_args(argv)
266
+ inside().drag(a.x1, a.y1, a.x2, a.y2, window=a.window, button=a.button)
267
+ return 0
268
+
269
+
270
+ def cmd_park(argv):
271
+ p = argparse.ArgumentParser(prog="shotbox park",
272
+ description="Inside `shotbox run`: move the pointer to the "
273
+ "screen's bottom-right corner, off whatever it "
274
+ "was hovering, and wait (up to --settle) for "
275
+ "the repaint.")
276
+ p.add_argument("--settle", type=float, default=2.0, metavar="SECS",
277
+ help="wait at most this long for the display to hold still (2)")
278
+ a = p.parse_args(argv)
279
+ inside().park(settle=a.settle)
280
+ return 0
281
+
282
+
283
+ def per_name(values, name, default):
284
+ """--fuzz and --max: the last NAME=VALUE for this picture, else the last
285
+ plain VALUE, else the default."""
286
+ named = [v.split("=", 1)[1] for v in values if v.split("=", 1)[0] == name and "=" in v]
287
+ plain = [v for v in values if "=" not in v]
288
+ return (named or plain or [default])[-1]
289
+
290
+
291
+ def cmd_compare(argv):
292
+ p = argparse.ArgumentParser(prog="shotbox compare",
293
+ description="Count the pixels that differ between two "
294
+ "images; fail if more than --max do. Given "
295
+ "two directories, compare each picture in "
296
+ "the first (or each NAME) with the one of "
297
+ "the same name in the second.")
298
+ p.add_argument("a", help="an image, or a directory of the pictures as they should be")
299
+ p.add_argument("b", help="an image, or a directory of fresh pictures")
300
+ p.add_argument("names", nargs="*", metavar="NAME",
301
+ help="with directories: just these (NAME or NAME.png)")
302
+ p.add_argument("--diff", metavar="OUT",
303
+ help="write the differences, marked in red; with directories, "
304
+ "OUT is a directory, and each picture that fails gets NAME-diff.png")
305
+ p.add_argument("--fuzz", action="append", default=[], metavar="[NAME=]PCT",
306
+ help="colors this close count as the same (0%%); NAME=PCT for one "
307
+ "picture (repeatable)")
308
+ p.add_argument("--max", action="append", default=[], metavar="[NAME=]N",
309
+ help="pixels allowed to differ (0); NAME=N for one picture (repeatable)")
310
+ a = p.parse_args(argv)
311
+ def fuzz(name):
312
+ return per_name(a.fuzz, name, "0%")
313
+
314
+ def most(name):
315
+ return int(per_name(a.max, name, 0))
316
+
317
+ dirs = Path(a.a).is_dir(), Path(a.b).is_dir()
318
+ if dirs[0] != dirs[1]:
319
+ p.error("compare two images, or two directories")
320
+ if not dirs[0]:
321
+ if a.names:
322
+ p.error("NAMEs go with two directories")
323
+ missing = [f for f in (a.a, a.b) if not Path(f).exists()]
324
+ if missing:
325
+ sys.stderr.write(f"shotbox: no {' or '.join(missing)}\n")
326
+ return 2
327
+ try:
328
+ n = x.compare(a.a, a.b, diff=a.diff, fuzz=fuzz(None))
329
+ except ValueError as e:
330
+ sys.stderr.write(f"shotbox: {e}\n")
331
+ return 2
332
+ print(f"{n} pixels differ")
333
+ return 0 if n <= most(None) else 1
334
+
335
+ names = [n.removesuffix(".png") for n in a.names] or sorted(
336
+ f.stem for f in Path(a.a).glob("*.png"))
337
+ if not names:
338
+ sys.stderr.write(f"shotbox: no pictures in {a.a}\n")
339
+ return 2
340
+ if a.diff:
341
+ Path(a.diff).mkdir(parents=True, exist_ok=True)
342
+ bad = []
343
+ for name in names:
344
+ ref, new = Path(a.a) / f"{name}.png", Path(a.b) / f"{name}.png"
345
+ missing = [str(f) for f in (ref, new) if not f.exists()]
346
+ if missing:
347
+ print(f"{name}: no {' or '.join(missing)}")
348
+ bad.append(name)
349
+ continue
350
+ diff = Path(a.diff) / f"{name}-diff.png" if a.diff else None
351
+ try:
352
+ n = x.compare(ref, new, fuzz=fuzz(name))
353
+ except ValueError as e:
354
+ print(f"{name}: {e}")
355
+ bad.append(name)
356
+ continue
357
+ if n > most(name):
358
+ if diff:
359
+ x.compare(ref, new, diff=diff, fuzz=fuzz(name))
360
+ print(f"{name}: {n} pixels differ" + (f"; see {diff}" if diff else ""))
361
+ bad.append(name)
362
+ else:
363
+ print(f"{name}: {'the same' if n == 0 else f'{n} pixels differ, allowed'}")
364
+ if bad:
365
+ print(f"differ: {', '.join(bad)}")
366
+ return 1
367
+ return 0
368
+
369
+
370
+ COMMANDS = {"run": cmd_run, "shoot": cmd_shoot, "term": term.main, "wait": cmd_wait,
371
+ "capture": cmd_capture, "compare": cmd_compare, "key": cmd_key,
372
+ "type": cmd_type, "click": pointer_command("click", "move the pointer and click"),
373
+ "move": pointer_command("move", "move the pointer, for a hover"),
374
+ "drag": cmd_drag, "park": cmd_park}
375
+
376
+
377
+ def main(argv=None):
378
+ argv = sys.argv[1:] if argv is None else argv
379
+ if not argv or argv[0] in ("-h", "--help") or argv[0] not in COMMANDS:
380
+ sys.stdout.write(__doc__.lstrip())
381
+ return 0 if not argv or argv[0] in ("-h", "--help") else 2
382
+ try:
383
+ return COMMANDS[argv[0]](argv[1:])
384
+ except (SessionError, xtest.XError) as e:
385
+ sys.stderr.write(f"shotbox: {e}\n")
386
+ return 1
387
+
388
+
389
+ if __name__ == "__main__":
390
+ sys.exit(main())
shotbox/screen.py ADDED
@@ -0,0 +1,206 @@
1
+ """A session's display, from Python: waiting on it, driving it, and taking
2
+ pictures of it. What the `shotbox wait`, `capture`, `key`, `click` and
3
+ friends commands do, without a process for each step.
4
+
5
+ Inside `shotbox run`, the display is the one in the environment:
6
+
7
+ import shotbox
8
+ screen = shotbox.here()
9
+ screen.wait_window("Text Editor")
10
+ screen.click(40, 60, window="Text Editor")
11
+ screen.wait_stable(window="Text Editor")
12
+ screen.capture("editor.png", window="Text Editor", park=True)
13
+
14
+ A Session is a Screen too, for a script that starts its own.
15
+
16
+ Anything that fails raises SessionError, saying what. With `failed` set (or
17
+ $SHOTBOX_FAILED), a failure first leaves a picture of the screen there.
18
+ """
19
+
20
+ import os
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from . import wl, wlinput, x, xtest
25
+
26
+
27
+ class SessionError(RuntimeError):
28
+ pass
29
+
30
+
31
+ def wait_for(what, test, timeout, interval=0.1, alive=None):
32
+ """Poll `test()` until it returns something true, or fail saying what."""
33
+ end = time.monotonic() + timeout
34
+ while True:
35
+ got = test()
36
+ if got:
37
+ return got
38
+ if alive is not None and alive.poll() is not None:
39
+ raise SessionError(f"gave up waiting for {what}: the program exited "
40
+ f"(status {alive.returncode})")
41
+ if time.monotonic() > end:
42
+ raise SessionError(f"gave up waiting for {what} after {timeout:g}s")
43
+ time.sleep(interval)
44
+
45
+
46
+ class Screen:
47
+ """The display in `env` (DISPLAY, XAUTHORITY and SHOTBOX_SCRATCH; the
48
+ process's own environment by default)."""
49
+
50
+ def __init__(self, env=None, failed=None):
51
+ self.env = dict(os.environ if env is None else env)
52
+ self.failed = failed or self.env.get("SHOTBOX_FAILED")
53
+ self._x = None
54
+
55
+ # --- failing --------------------------------------------------------------
56
+
57
+ def fail(self, message):
58
+ """Raise SessionError(message), first taking a picture of the screen
59
+ to `failed` if it's set, and saying where."""
60
+ if self.failed and x.try_capture(self.env, self.failed, self.backend):
61
+ message += f"; the screen then: {self.failed}"
62
+ raise SessionError(message)
63
+
64
+ def until(self, what, test, timeout=30, alive=None):
65
+ """Poll `test()` until it returns something true, and return that;
66
+ or fail, saying it gave up waiting for `what`. With `alive` (a
67
+ process), fail sooner if it exits."""
68
+ try:
69
+ return wait_for(what, test, timeout, alive=alive)
70
+ except SessionError as e:
71
+ self.fail(str(e))
72
+
73
+ # --- waiting --------------------------------------------------------------
74
+
75
+ def test(self, kind, arg="", window=None):
76
+ """What to say, and what to poll, for a wait: window (a regex for
77
+ its title, or app=RE for its app id), port,
78
+ file, ready, or stable (seconds; `window` to watch just that one)."""
79
+ if kind == "window":
80
+ return f"a {x.matcher(arg)[1]}", lambda: self.backend.find_window(self.env, arg)
81
+ if kind == "port":
82
+ return f"port {arg}", lambda: x.port_open(int(arg))
83
+ if kind == "file":
84
+ return f"{arg} to exist", lambda: Path(arg).exists()
85
+ if kind == "ready":
86
+ ready = Path(self.env["SHOTBOX_SCRATCH"]) / "ready"
87
+ return "the program to say it's ready", ready.exists
88
+ if kind == "stable":
89
+ quiet = float(arg) if arg else 0.5
90
+ where = f"the window {window!r}" if window else "the display"
91
+ return (f"{where} to hold still for {quiet:g}s",
92
+ x.Still(self.env, window, quiet, self.backend))
93
+ raise SessionError(f"don't know how to wait for {kind!r} "
94
+ "(window, port, file, ready or stable)")
95
+
96
+ def wait_window(self, name, timeout=30):
97
+ """Wait for a viewable window whose title matches `name` (a regex,
98
+ matched in full), or, with app=RE, whose app id does; its (id, name,
99
+ width, height, x, y)."""
100
+ return self.until(*self.test("window", name), timeout)
101
+
102
+ def wait_port(self, port, timeout=30):
103
+ self.until(*self.test("port", port), timeout)
104
+
105
+ def wait_file(self, path, timeout=30):
106
+ self.until(*self.test("file", path), timeout)
107
+
108
+ def wait_ready(self, timeout=30):
109
+ """Wait for $SHOTBOX_SCRATCH/ready, which `shotbox term` writes."""
110
+ self.until(*self.test("ready"), timeout)
111
+
112
+ def wait_stable(self, seconds=0.5, window=None, timeout=30):
113
+ """Wait until the display, or the window named `window`, has looked
114
+ the same for `seconds`: for the paint after a click, a panel sliding
115
+ in, a toast going away."""
116
+ self.until(*self.test("stable", seconds, window), timeout)
117
+
118
+ # --- windows and pictures -------------------------------------------------
119
+
120
+ def window(self, name):
121
+ """The window `name` asks for (a title regex, or app=RE), or fail."""
122
+ w = self.backend.find_window(self.env, name)
123
+ if not w:
124
+ self.fail(f"no {x.matcher(name)[1]}")
125
+ return w
126
+
127
+ def capture(self, out, window=None, crop=None, park=False):
128
+ """A picture of the display, or of the window named `window`, to
129
+ `out` (a PNG), then cropped (WxH+X+Y). With `park`, the pointer
130
+ goes out of the way first, so nothing shows its hover."""
131
+ if park:
132
+ self.park()
133
+ self.backend.capture(self.env, out, window=self.window(window) if window else None,
134
+ crop=crop)
135
+ return Path(out)
136
+
137
+ # --- input ----------------------------------------------------------------
138
+
139
+ @property
140
+ def wayland(self):
141
+ """Whether this is a Wayland session (under sway) rather than X11."""
142
+ return bool(self.env.get("SWAYSOCK"))
143
+
144
+ @property
145
+ def backend(self):
146
+ """The module that finds windows and takes pictures: wl or x."""
147
+ return wl if self.wayland else x
148
+
149
+ @property
150
+ def xt(self):
151
+ """The input connection, opened on first use and kept: XTEST on
152
+ X11, a virtual keyboard and pointer on Wayland."""
153
+ if self._x is None:
154
+ self._x = (wlinput if self.wayland else xtest).Display(self.env)
155
+ return self._x
156
+
157
+ def _at(self, px, py, window):
158
+ if not window:
159
+ return px, py
160
+ w = self.window(window)
161
+ return w[4] + px, w[5] + py
162
+
163
+ def key(self, *chords):
164
+ """Press and release each chord in turn: ctrl+comma, Return,
165
+ alt+shift+Tab."""
166
+ for c in chords:
167
+ self.xt.chord(c)
168
+
169
+ def type(self, text):
170
+ """Type ASCII text into whatever has the keyboard focus."""
171
+ self.xt.type(text)
172
+
173
+ def move(self, px, py, window=None):
174
+ """Move the pointer to X, Y on the screen, or inside `window`."""
175
+ self.xt.move(*self._at(px, py, window))
176
+
177
+ def click(self, px, py, window=None, button=1, double=False):
178
+ self.xt.click(*self._at(px, py, window), button=button, count=2 if double else 1)
179
+
180
+ def drag(self, x1, y1, x2, y2, window=None, button=1):
181
+ dx, dy = self._at(0, 0, window)
182
+ self.xt.drag(x1 + dx, y1 + dy, x2 + dx, y2 + dy, button=button)
183
+
184
+ def park(self, settle=2.0):
185
+ """Move the pointer to the screen's bottom-right corner, off whatever
186
+ it was hovering, then wait up to `settle` seconds for the display to
187
+ hold still, for the repaint. A display that keeps changing (a video,
188
+ a spinner) isn't a failure here: it's taken as it is."""
189
+ self.xt.move(self.xt.width - 1, self.xt.height - 1)
190
+ if settle:
191
+ try:
192
+ wait_for("", x.Still(self.env, quiet=0.3, backend=self.backend), settle)
193
+ except SessionError:
194
+ pass
195
+
196
+ def close(self):
197
+ if self._x is not None:
198
+ self._x.close()
199
+ self._x = None
200
+
201
+
202
+ def here():
203
+ """The display of the `shotbox run` this is inside."""
204
+ if not os.environ.get("SHOTBOX_SCRATCH"):
205
+ raise SessionError("not inside a shotbox session")
206
+ return Screen()