deskpilot 0.2.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.
deskpilot/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Desktop control for agents on Hyprland."""
deskpilot/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """deskpilot: drive Hyprland desktop windows from an agent.
2
+
3
+ Coordinates for move, click and scroll are pixels in the window's last `deskpilot shot`, so read the screenshot,
4
+ pick a point, click it. Pass --scale 1 to give window pixels instead.
5
+
6
+ Clicks, scrolls, and keys and text sent to a --window run inside the compositor through the deskpilot Hyprland plugin
7
+ (`deskpilot plugin load`), so the user's pointer and keyboard focus stay where they are. --take-pointer and
8
+ --take-focus move the user's own pointer or focus instead.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .desk import EXPECTED, Desk
19
+
20
+
21
+ def _print(data, as_json: bool) -> None:
22
+ if as_json:
23
+ print(json.dumps(data, indent=2))
24
+ elif isinstance(data, list):
25
+ for row in data:
26
+ print(row if isinstance(row, str) else json.dumps(row))
27
+ else:
28
+ print(data if isinstance(data, str) else json.dumps(data))
29
+
30
+
31
+ SHOW_HELP = "bring a window that is not on screen into view by focusing it, instead of refusing"
32
+
33
+
34
+ def _point_parser(sub, name: str, helptext: str) -> argparse.ArgumentParser:
35
+ """A command that acts at a point of a window's screenshot."""
36
+ s = sub.add_parser(name, help=helptext)
37
+ s.add_argument("window")
38
+ s.add_argument("x", type=float)
39
+ s.add_argument("y", type=float)
40
+ s.add_argument("--scale", type=float, help="the scale the point is in; 1 for window pixels")
41
+ s.add_argument("--shot", help="the shot id the point is in (default: the window's latest shot)")
42
+ s.add_argument("--show", action="store_true", help=SHOW_HELP)
43
+ s.add_argument(
44
+ "--take-pointer", action="store_true", help="move the user's own pointer there instead of leaving it in place"
45
+ )
46
+ return s
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ p = argparse.ArgumentParser(
51
+ prog="deskpilot", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
52
+ )
53
+ p.add_argument("--json", action="store_true", help="print JSON")
54
+ sub = p.add_subparsers(dest="cmd", required=True)
55
+
56
+ sub.add_parser("windows", help="list mapped windows with geometry")
57
+
58
+ s = sub.add_parser("focus", help="focus a window")
59
+ s.add_argument("window")
60
+
61
+ s = sub.add_parser("shot", help="screenshot a window; later clicks use its pixels")
62
+ s.add_argument("window")
63
+ s.add_argument("-o", "--out", type=Path)
64
+ s.add_argument("--max-width", type=int, default=1400, help="scale down wider windows to this width")
65
+ s.add_argument("--show", action="store_true", help=SHOW_HELP)
66
+
67
+ _point_parser(sub, "move", "show the agent cursor at a point")
68
+ s = _point_parser(sub, "click", "click a point")
69
+ s.add_argument("--button", choices=["left", "right", "middle"], default="left")
70
+ s.add_argument("--double", action="store_true")
71
+ s = _point_parser(sub, "scroll", "scroll at a point; positive is up")
72
+ s.add_argument("dy", type=int)
73
+
74
+ s = sub.add_parser("plugin", help="build, load or check the deskpilot Hyprland plugin")
75
+ s.add_argument("action", choices=["build", "load", "status"])
76
+
77
+ focus_help = "focus the window and press through the input device instead of leaving focus in place"
78
+ s = sub.add_parser("key", help="press a key or chord, e.g. Return, ctrl+a, alt+F4")
79
+ s.add_argument("combo")
80
+ s.add_argument("--window", help="press it in this window; without it the keys go to the focused window")
81
+ s.add_argument("--take-focus", action="store_true", help=focus_help)
82
+
83
+ s = sub.add_parser("type", help="type text (US layout)")
84
+ s.add_argument("text")
85
+ s.add_argument("--window", help="type into this window; without it the keys go to the focused window")
86
+ s.add_argument("--take-focus", action="store_true", help=focus_help)
87
+ s.add_argument(
88
+ "--delay", type=float, default=0.03, help="seconds each key is held and between keys; games want 0.1 or more"
89
+ )
90
+
91
+ s = sub.add_parser("wait-for", help="wait for a window to appear (or --gone to close)")
92
+ s.add_argument("window")
93
+ s.add_argument("--timeout", type=float, default=60)
94
+ s.add_argument("--gone", action="store_true")
95
+
96
+ sub.add_parser("cursor", help="print the cursor's global position")
97
+ sub.add_parser("mcp", help="run the MCP server on stdio")
98
+ return p
99
+
100
+
101
+ def run(args: argparse.Namespace, desk: Desk) -> object:
102
+ match args.cmd:
103
+ case "windows":
104
+ return [w.to_dict() for w in desk.windows()]
105
+ case "focus":
106
+ return desk.focus(args.window).to_dict()
107
+ case "shot":
108
+ return desk.shot(args.window, args.out, args.max_width, args.show).to_dict()
109
+ case "move":
110
+ gx, gy = desk.move(
111
+ args.window, args.x, args.y, args.scale, args.take_pointer, shot=args.shot, show=args.show
112
+ )
113
+ return {"x": gx, "y": gy}
114
+ case "click":
115
+ count = 2 if args.double else 1
116
+ gx, gy = desk.click(
117
+ args.window,
118
+ args.x,
119
+ args.y,
120
+ args.button,
121
+ count,
122
+ args.scale,
123
+ args.take_pointer,
124
+ shot=args.shot,
125
+ show=args.show,
126
+ )
127
+ return {"x": gx, "y": gy}
128
+ case "scroll":
129
+ gx, gy = desk.scroll(
130
+ args.window, args.x, args.y, args.dy, args.scale, args.take_pointer, shot=args.shot, show=args.show
131
+ )
132
+ return {"x": gx, "y": gy}
133
+ case "key":
134
+ desk.key(args.combo, args.window, args.take_focus)
135
+ return "ok"
136
+ case "type":
137
+ desk.type(args.text, args.window, args.delay, args.take_focus)
138
+ return "ok"
139
+ case "wait-for":
140
+ win = desk.wait_for(args.window, args.timeout, args.gone)
141
+ return win.to_dict() if win else "gone"
142
+ case "plugin":
143
+ if args.action == "build":
144
+ return {"path": str(desk.plugin.build())}
145
+ if args.action == "load":
146
+ return {"path": str(desk.plugin.load()), **desk.plugin.status()}
147
+ return desk.plugin.status()
148
+ case "cursor":
149
+ x, y = desk.hypr.cursor()
150
+ return {"x": x, "y": y}
151
+ raise AssertionError(args.cmd)
152
+
153
+
154
+ def main(argv: list[str] | None = None) -> int:
155
+ args = build_parser().parse_args(argv)
156
+ if args.cmd == "mcp":
157
+ from .mcp_server import serve
158
+
159
+ serve()
160
+ return 0
161
+ desk = Desk()
162
+ try:
163
+ _print(run(args, desk), args.json)
164
+ except EXPECTED as error:
165
+ print(f"deskpilot: {error}", file=sys.stderr)
166
+ return 2
167
+ finally:
168
+ desk.close()
169
+ return 0
170
+
171
+
172
+ if __name__ == "__main__":
173
+ sys.exit(main())
deskpilot/desk.py ADDED
@@ -0,0 +1,221 @@
1
+ """The actions an agent takes, shared by the CLI and the MCP server.
2
+
3
+ Clicks, scrolls and keys for a named window go through the deskpilot Hyprland plugin, which leaves the user's
4
+ pointer and keyboard focus where they were; the agent cursor overlay shows where clicks land. `take_pointer` and
5
+ `take_focus` instead move the user's own pointer or focus and press through the uinput device.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from . import keys, screen
14
+ from .hypr import AmbiguousWindow, HyprctlError, Hyprland, Window, WindowNotFound
15
+ from .overlay import AgentCursor
16
+ from .plugin import Plugin, PluginError
17
+
18
+
19
+ class NotVisible(LookupError):
20
+ pass
21
+
22
+
23
+ # Failures an agent should read and act on: a refusal, a missing window, a tool that failed. Anything else is a bug.
24
+ EXPECTED = (
25
+ WindowNotFound,
26
+ AmbiguousWindow,
27
+ NotVisible,
28
+ HyprctlError,
29
+ PluginError,
30
+ ValueError,
31
+ TimeoutError,
32
+ RuntimeError,
33
+ )
34
+
35
+ # How long `show` waits for a focused window to come on screen: a workspace switch or a scrolling layout's
36
+ # animation has to finish before the window's area is its own.
37
+ SHOW_TIMEOUT = 3.0
38
+
39
+
40
+ class Desk:
41
+ def __init__(self, hypr: Hyprland | None = None, input_factory=None, plugin=None, cursor=None):
42
+ self.hypr = hypr or Hyprland()
43
+ self.plugin = plugin or Plugin()
44
+ self.cursor = cursor or AgentCursor()
45
+ self._input_factory = input_factory
46
+ self._input = None
47
+
48
+ @property
49
+ def input(self):
50
+ if self._input is None:
51
+ if self._input_factory is None:
52
+ from .uinput import VirtualInput
53
+
54
+ self._input_factory = VirtualInput
55
+ self._input = self._input_factory()
56
+ return self._input
57
+
58
+ def close(self) -> None:
59
+ self.cursor.close()
60
+ if self._input is not None:
61
+ self._input.close()
62
+ self._input = None
63
+
64
+ def windows(self) -> list[Window]:
65
+ return self.hypr.windows()
66
+
67
+ def window(self, query: str) -> Window:
68
+ return self.hypr.find(query)
69
+
70
+ def focus(self, query: str) -> Window:
71
+ win = self.window(query)
72
+ self.hypr.focus(win)
73
+ return win
74
+
75
+ def _on_screen(self, win: Window, show: bool) -> Window:
76
+ """The window, on screen: a screenshot or a click reaches only what is showing. A hidden window is an error
77
+ unless `show`, which focuses it, since that switches the user's workspace or scrolls their layout."""
78
+ if win.visible:
79
+ return win
80
+ if not show:
81
+ raise NotVisible(
82
+ f"{win.cls} ({win.address}) is not on screen: its workspace {win.workspace} is not showing or it is "
83
+ "scrolled off its monitor. Pass --show (show over MCP) to bring it into view, which focuses it"
84
+ )
85
+ self.hypr.focus(win)
86
+ deadline = time.monotonic() + SHOW_TIMEOUT
87
+ while time.monotonic() < deadline:
88
+ win = self.window(win.address)
89
+ if win.visible:
90
+ return win
91
+ time.sleep(0.05)
92
+ raise NotVisible(f"{win.cls} ({win.address}) did not come on screen within {SHOW_TIMEOUT}s of focusing it")
93
+
94
+ def shot(
95
+ self, query: str, out: Path | None = None, max_width: int = screen.DEFAULT_MAX_WIDTH, show: bool = False
96
+ ) -> screen.Shot:
97
+ return screen.capture(self._on_screen(self.window(query), show), out, max_width)
98
+
99
+ def point(
100
+ self,
101
+ query: str,
102
+ x: float,
103
+ y: float,
104
+ scale: float | None = None,
105
+ shot: str | None = None,
106
+ show: bool = False,
107
+ ) -> tuple[Window, int, int]:
108
+ """Resolve a point to a global position. The point is in pixels of `shot`, or of the window's latest shot,
109
+ or at `scale` (1 for window pixels); with no shot at all it is in window pixels."""
110
+ win = self._on_screen(self.window(query), show)
111
+ if scale is None:
112
+ taken = screen.load(shot) if shot else screen.latest(win.address)
113
+ if taken:
114
+ taken.check(win)
115
+ scale = taken.scale if taken else 1.0
116
+ gx, gy = screen.to_global(win, x, y, scale)
117
+ return win, gx, gy
118
+
119
+ def _take_pointer(self, win: Window, gx: int, gy: int) -> None:
120
+ self.hypr.focus(win)
121
+ self.hypr.move_cursor(gx, gy)
122
+ time.sleep(0.15)
123
+ self.input.nudge()
124
+
125
+ def move(
126
+ self,
127
+ query: str,
128
+ x: float,
129
+ y: float,
130
+ scale: float | None = None,
131
+ take_pointer: bool = False,
132
+ *,
133
+ shot: str | None = None,
134
+ show: bool = False,
135
+ ) -> tuple[int, int]:
136
+ """Show the agent cursor at a point. take_pointer also moves the user's pointer, for a hover."""
137
+ win, gx, gy = self.point(query, x, y, scale, shot, show)
138
+ self.cursor.glide(gx, gy)
139
+ if take_pointer:
140
+ self._take_pointer(win, gx, gy)
141
+ return gx, gy
142
+
143
+ def click(
144
+ self,
145
+ query: str,
146
+ x: float,
147
+ y: float,
148
+ button: str = "left",
149
+ count: int = 1,
150
+ scale: float | None = None,
151
+ take_pointer: bool = False,
152
+ *,
153
+ shot: str | None = None,
154
+ show: bool = False,
155
+ ) -> tuple[int, int]:
156
+ win, gx, gy = self.point(query, x, y, scale, shot, show)
157
+ self.cursor.glide(gx, gy)
158
+ if take_pointer:
159
+ self._take_pointer(win, gx, gy)
160
+ self.input.click(button, count)
161
+ else:
162
+ self.plugin.click(win.address, gx, gy, button, count)
163
+ return gx, gy
164
+
165
+ def scroll(
166
+ self,
167
+ query: str,
168
+ x: float,
169
+ y: float,
170
+ dy: int,
171
+ scale: float | None = None,
172
+ take_pointer: bool = False,
173
+ *,
174
+ shot: str | None = None,
175
+ show: bool = False,
176
+ ) -> tuple[int, int]:
177
+ win, gx, gy = self.point(query, x, y, scale, shot, show)
178
+ self.cursor.glide(gx, gy)
179
+ if take_pointer:
180
+ self._take_pointer(win, gx, gy)
181
+ self.input.scroll(dy=dy)
182
+ else:
183
+ self.plugin.scroll(win.address, gx, gy, dy)
184
+ return gx, gy
185
+
186
+ def key(self, combo: str, query: str | None = None, take_focus: bool = False) -> None:
187
+ """Press a chord in a window without focusing it, or with take_focus focus it and press through uinput.
188
+ With no window the keys go to whatever has focus."""
189
+ if query and not take_focus:
190
+ self.plugin.keys(self.window(query).address, keys.chord_events(combo))
191
+ return
192
+ if query:
193
+ self.focus(query)
194
+ time.sleep(0.1)
195
+ self.input.chord(combo)
196
+
197
+ def type(self, text: str, query: str | None = None, delay: float = 0.03, take_focus: bool = False) -> None:
198
+ if query and not take_focus:
199
+ events = [event for code, shift in keys.text_strokes(text) for event in keys.stroke_events(code, shift)]
200
+ self.plugin.keys(self.window(query).address, events, hold=delay, gap=delay)
201
+ return
202
+ if query:
203
+ self.focus(query)
204
+ time.sleep(0.1)
205
+ self.input.type_text(text, delay)
206
+
207
+ def wait_for(self, query: str, timeout: float = 60, gone: bool = False) -> Window | None:
208
+ """Wait until a window matching `query` exists (or, with gone, no longer does)."""
209
+ deadline = time.monotonic() + timeout
210
+ while True:
211
+ try:
212
+ win = self.window(query)
213
+ if not gone:
214
+ return win
215
+ except WindowNotFound:
216
+ if gone:
217
+ return None
218
+ if time.monotonic() >= deadline:
219
+ state = "still open" if gone else "did not appear"
220
+ raise TimeoutError(f"window '{query}' {state} within {timeout:g}s")
221
+ time.sleep(0.5)
deskpilot/hypr.py ADDED
@@ -0,0 +1,222 @@
1
+ """Hyprland through hyprctl: windows, monitors, focus and the cursor.
2
+
3
+ Hyprland 0.57 replaced the string dispatchers (`movecursor 10 20`) with Lua ones (`hl.dsp.cursor.move({x=10,y=20})`).
4
+ Each dispatcher here tries the Lua form first and falls back to the string form, so both versions work.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import socket
12
+ import subprocess
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ Runner = Callable[[list[str]], str]
18
+
19
+
20
+ class HyprctlError(RuntimeError):
21
+ pass
22
+
23
+
24
+ def socket_alive(path: Path) -> bool:
25
+ """Whether a Hyprland control socket accepts a connection. A crashed or ended session leaves its file behind."""
26
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
27
+ sock.settimeout(1)
28
+ try:
29
+ sock.connect(str(path))
30
+ except OSError:
31
+ return False
32
+ return True
33
+
34
+
35
+ def pick_instance(signature: str | None, hypr_dir: Path, alive: Callable[[Path], bool] = socket_alive) -> str:
36
+ """The Hyprland instance to talk to: the environment's, unless its socket is dead, then the only live one.
37
+
38
+ A shell started before Hyprland restarted keeps the old session's HYPRLAND_INSTANCE_SIGNATURE, and hyprctl then
39
+ fails on every call. Several live instances are an error naming them, never a guess.
40
+ """
41
+ if signature and alive(hypr_dir / signature / ".socket.sock"):
42
+ return signature
43
+ folders = sorted(p.name for p in hypr_dir.iterdir() if p.is_dir()) if hypr_dir.is_dir() else []
44
+ live = [name for name in folders if alive(hypr_dir / name / ".socket.sock")]
45
+ stale = f"HYPRLAND_INSTANCE_SIGNATURE={signature} has no live socket. " if signature else ""
46
+ if len(live) == 1:
47
+ return live[0]
48
+ if not live:
49
+ raise HyprctlError(f"{stale}No running Hyprland instance under {hypr_dir}.")
50
+ raise HyprctlError(
51
+ f"{stale}Several Hyprland instances are running, set HYPRLAND_INSTANCE_SIGNATURE to one: {', '.join(live)}"
52
+ )
53
+
54
+
55
+ def current_instance() -> str:
56
+ """Chosen per call, not once: a long-lived MCP server outlives a Hyprland restart. It costs one connect."""
57
+ runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
58
+ return pick_instance(os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"), Path(runtime) / "hypr")
59
+
60
+
61
+ def run_hyprctl(args: list[str]) -> str:
62
+ cmd = ["hyprctl", "-i", current_instance(), *args]
63
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=10)
64
+ if result.returncode != 0:
65
+ detail = (result.stderr.strip() or result.stdout.strip() or "no output").splitlines()[0]
66
+ raise HyprctlError(f"hyprctl {' '.join(args)} exited {result.returncode}: {detail}")
67
+ return result.stdout
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Window:
72
+ address: str
73
+ cls: str
74
+ title: str
75
+ x: int
76
+ y: int
77
+ width: int
78
+ height: int
79
+ monitor: int
80
+ workspace: str
81
+ xwayland: bool
82
+ pid: int
83
+ focused: bool = False
84
+ # The ext-foreign-toplevel identifier, which `grim -T` captures by. Empty on a Hyprland too old to report it.
85
+ stable_id: str = ""
86
+ # Some of the window lies on a monitor showing its workspace. False for a hidden workspace and for a column a
87
+ # scrolling layout has moved off every monitor.
88
+ visible: bool = True
89
+
90
+ def to_dict(self) -> dict:
91
+ return {
92
+ "address": self.address,
93
+ "class": self.cls,
94
+ "title": self.title,
95
+ "x": self.x,
96
+ "y": self.y,
97
+ "width": self.width,
98
+ "height": self.height,
99
+ "monitor": self.monitor,
100
+ "workspace": self.workspace,
101
+ "xwayland": self.xwayland,
102
+ "visible": self.visible,
103
+ "pid": self.pid,
104
+ "focused": self.focused,
105
+ }
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Monitor:
110
+ """A monitor's area in global logical pixels, and the workspaces it shows."""
111
+
112
+ x: int
113
+ y: int
114
+ width: int
115
+ height: int
116
+ showing: frozenset[str]
117
+
118
+ @classmethod
119
+ def from_json(cls, m: dict) -> Monitor:
120
+ scale = m.get("scale") or 1
121
+ width, height = round(m["width"] / scale), round(m["height"] / scale)
122
+ if m.get("transform", 0) % 2: # rotated a quarter turn, flipped or not
123
+ width, height = height, width
124
+ # A special workspace covers the monitor's normal one while it is open.
125
+ special = str((m.get("specialWorkspace") or {}).get("name") or "")
126
+ shown = special or str((m.get("activeWorkspace") or {}).get("name", ""))
127
+ return cls(m["x"], m["y"], width, height, frozenset({shown}))
128
+
129
+ def shows(self, workspace: str, x: int, y: int, width: int, height: int) -> bool:
130
+ overlaps = x < self.x + self.width and self.x < x + width and y < self.y + self.height and self.y < y + height
131
+ return workspace in self.showing and overlaps
132
+
133
+
134
+ class WindowNotFound(LookupError):
135
+ pass
136
+
137
+
138
+ class AmbiguousWindow(LookupError):
139
+ pass
140
+
141
+
142
+ class Hyprland:
143
+ def __init__(self, runner: Runner = run_hyprctl):
144
+ self._run = runner
145
+
146
+ def _json(self, *args: str):
147
+ out = self._run(["-j", *args])
148
+ return json.loads(out) if out.strip() else None
149
+
150
+ def windows(self) -> list[Window]:
151
+ active = self._json("activewindow") or {}
152
+ monitors = [Monitor.from_json(m) for m in self._json("monitors") or [] if not m.get("disabled")]
153
+ found = []
154
+ for c in self._json("clients") or []:
155
+ if not c.get("mapped", True) or c.get("hidden"):
156
+ continue
157
+ x, y = c["at"]
158
+ w, h = c["size"]
159
+ workspace = str((c.get("workspace") or {}).get("name", ""))
160
+ found.append(
161
+ Window(
162
+ address=c["address"],
163
+ cls=c.get("class", ""),
164
+ title=c.get("title", ""),
165
+ x=x,
166
+ y=y,
167
+ width=w,
168
+ height=h,
169
+ monitor=c.get("monitor", -1),
170
+ workspace=workspace,
171
+ xwayland=bool(c.get("xwayland")),
172
+ pid=c.get("pid", 0),
173
+ focused=c["address"] == active.get("address"),
174
+ stable_id=str(c.get("stableId") or ""),
175
+ visible=any(m.shows(workspace, x, y, w, h) for m in monitors),
176
+ )
177
+ )
178
+ return found
179
+
180
+ def find(self, query: str) -> Window:
181
+ return find_window(self.windows(), query)
182
+
183
+ def cursor(self) -> tuple[int, int]:
184
+ pos = self._json("cursorpos") or {}
185
+ return int(pos.get("x", 0)), int(pos.get("y", 0))
186
+
187
+ def _dispatch(self, lua: str, legacy: str) -> None:
188
+ """Run the Lua dispatcher, or the string one on Hyprland before 0.57. Either failing exits non-zero."""
189
+ try:
190
+ if self._run(["dispatch", lua]).strip() == "ok":
191
+ return
192
+ except HyprctlError:
193
+ pass
194
+ out = self._run(["dispatch", *legacy.split(" ", 1)])
195
+ if out.strip() != "ok":
196
+ raise HyprctlError(f"hyprctl dispatch {legacy} failed: {out.strip()}")
197
+
198
+ def move_cursor(self, gx: int, gy: int) -> None:
199
+ self._dispatch(f"hl.dsp.cursor.move({{x={gx},y={gy}}})", f"movecursor {gx} {gy}")
200
+
201
+ def focus(self, window: Window) -> None:
202
+ self._dispatch(f'hl.dsp.focus({{window="address:{window.address}"}})', f"focuswindow address:{window.address}")
203
+
204
+
205
+ def find_window(windows: list[Window], query: str) -> Window:
206
+ """Match an address exactly, then a class exactly, then a substring of class or title, all case-insensitive.
207
+
208
+ A query that matches more than one window at the same level is an error naming them, never a guess.
209
+ """
210
+ q = query.lower()
211
+ levels = [
212
+ [w for w in windows if w.address.lower() == q or w.address.lower() == f"0x{q}"],
213
+ [w for w in windows if w.cls.lower() == q],
214
+ [w for w in windows if q in w.cls.lower() or q in w.title.lower()],
215
+ ]
216
+ for matches in levels:
217
+ if len(matches) == 1:
218
+ return matches[0]
219
+ if len(matches) > 1:
220
+ names = "; ".join(f"{w.address} {w.cls} '{w.title[:40]}'" for w in matches)
221
+ raise AmbiguousWindow(f"'{query}' matches {len(matches)} windows, use an address: {names}")
222
+ raise WindowNotFound(f"no window matches '{query}'")