kwin-mcp-server 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.
- kwin_bridge/__init__.py +15 -0
- kwin_bridge/_env.py +61 -0
- kwin_bridge/a11y.py +394 -0
- kwin_bridge/atspi_dbus.py +408 -0
- kwin_bridge/doctor.py +187 -0
- kwin_bridge/input.py +412 -0
- kwin_bridge/preflight.py +109 -0
- kwin_bridge/screenshot.py +190 -0
- kwin_bridge/server.py +422 -0
- kwin_bridge/windows.py +201 -0
- kwin_mcp_server-0.2.0.dist-info/METADATA +380 -0
- kwin_mcp_server-0.2.0.dist-info/RECORD +16 -0
- kwin_mcp_server-0.2.0.dist-info/WHEEL +5 -0
- kwin_mcp_server-0.2.0.dist-info/entry_points.txt +4 -0
- kwin_mcp_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- kwin_mcp_server-0.2.0.dist-info/top_level.txt +1 -0
kwin_bridge/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""kwin-bridge: control native Wayland windows on KDE Plasma via MCP.
|
|
2
|
+
|
|
3
|
+
This package wraps kdotool (enumeration/geometry/focus), spectacle
|
|
4
|
+
(screen capture) and a /dev/uinput virtual device (synthetic input) to give
|
|
5
|
+
an MCP client the same capabilities cua-driver provides on X11. See the
|
|
6
|
+
module docstrings for the Wayland-specific caveats (focus-then-inject, single
|
|
7
|
+
cursor, no background targeting).
|
|
8
|
+
|
|
9
|
+
Deliberately imports nothing heavy here: submodules are imported explicitly by
|
|
10
|
+
the code that uses them (server.py, preflight.py, tests). This keeps
|
|
11
|
+
``import kwin_bridge`` light so the dependency preflight can run with a plain
|
|
12
|
+
system Python that does not yet have uinput/jeepney installed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__all__ = ["windows", "screenshot", "input", "a11y", "doctor"]
|
kwin_bridge/_env.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared environment setup for kwin-bridge subprocess calls.
|
|
2
|
+
|
|
3
|
+
kdotool / spectacle / kdotool talk to the KDE session bus over D-Bus and to
|
|
4
|
+
the compositor over a display connection. When an MCP client launches
|
|
5
|
+
server.py it may NOT forward DBUS_SESSION_BUS_ADDRESS / WAYLAND_DISPLAY /
|
|
6
|
+
DISPLAY. kdotool then fails with "X11 for dbus-daemon autolaunch was
|
|
7
|
+
disabled", and spectacle crashes (SIGABRT, rc -6) with no display. We default
|
|
8
|
+
all three from the well-known systemd-user-session locations so the bridge
|
|
9
|
+
works regardless of how the parent spawned it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import glob
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_wayland_display() -> str:
|
|
19
|
+
uid = os.getuid()
|
|
20
|
+
runtime = f"/run/user/{uid}"
|
|
21
|
+
# Common default first.
|
|
22
|
+
candidates = ["wayland-0", "wayland-1"]
|
|
23
|
+
for c in candidates:
|
|
24
|
+
if os.path.exists(os.path.join(runtime, c)):
|
|
25
|
+
return c
|
|
26
|
+
# Otherwise pick the first wayland-* socket present.
|
|
27
|
+
found = sorted(glob.glob(os.path.join(runtime, "wayland-*")))
|
|
28
|
+
if found:
|
|
29
|
+
return os.path.basename(found[0])
|
|
30
|
+
return "wayland-0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_display() -> str:
|
|
34
|
+
# XWayland typically listens on :1 under a Wayland session.
|
|
35
|
+
for d in (":1", ":0"):
|
|
36
|
+
if os.path.exists(f"/tmp/.X11-unix/X{d[1:]}"):
|
|
37
|
+
return d
|
|
38
|
+
return ":1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def base_env() -> dict:
|
|
42
|
+
env = dict(os.environ)
|
|
43
|
+
uid = os.getuid()
|
|
44
|
+
|
|
45
|
+
if not env.get("DBUS_SESSION_BUS_ADDRESS"):
|
|
46
|
+
candidate = f"unix:path=/run/user/{uid}/bus"
|
|
47
|
+
if os.path.exists(f"/run/user/{uid}/bus"):
|
|
48
|
+
env["DBUS_SESSION_BUS_ADDRESS"] = candidate
|
|
49
|
+
|
|
50
|
+
if not env.get("WAYLAND_DISPLAY"):
|
|
51
|
+
env["WAYLAND_DISPLAY"] = _default_wayland_display()
|
|
52
|
+
|
|
53
|
+
if not env.get("XDG_RUNTIME_DIR"):
|
|
54
|
+
runtime = f"/run/user/{uid}"
|
|
55
|
+
if os.path.isdir(runtime):
|
|
56
|
+
env["XDG_RUNTIME_DIR"] = runtime
|
|
57
|
+
|
|
58
|
+
if not env.get("DISPLAY"):
|
|
59
|
+
env["DISPLAY"] = _default_display()
|
|
60
|
+
|
|
61
|
+
return env
|
kwin_bridge/a11y.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Accessibility tree (AT-SPI) introspection for KDE Plasma on Wayland.
|
|
3
|
+
|
|
4
|
+
Many GTK/Qt/KDE applications expose an AT-SPI accessibility tree even on
|
|
5
|
+
Wayland, where the old X11 `get_window_state` trick does not work. This
|
|
6
|
+
module returns a structured list of interactive elements (with their on-screen
|
|
7
|
+
bounds, state flags and the actions they expose) so a caller can target them
|
|
8
|
+
three ways:
|
|
9
|
+
|
|
10
|
+
* by index (``click_element``, ``perform_action``, ``set_value``)
|
|
11
|
+
* semantically (``resolve_elements`` with role / name / text filters)
|
|
12
|
+
* by coordinates (fall back to the input module's absolute clicks)
|
|
13
|
+
|
|
14
|
+
Two backends are supported and chosen automatically:
|
|
15
|
+
|
|
16
|
+
* ``atspi_dbus`` (preferred) - a pure-D-Bus AT-SPI client (jeepney) with no
|
|
17
|
+
system dependency. This is what works on Arch, where pyatspi is not
|
|
18
|
+
packaged.
|
|
19
|
+
* ``pyatspi`` - only if the legacy pyatspi module happens to be importable.
|
|
20
|
+
|
|
21
|
+
The module degrades gracefully when neither backend is available, so calls
|
|
22
|
+
fall back to coordinate input instead of crashing.
|
|
23
|
+
|
|
24
|
+
Note: AT-SPI element bounds are in screen coordinates, so they feed directly
|
|
25
|
+
into the input module's absolute click coordinates.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
from . import atspi_dbus
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class A11yElement:
|
|
39
|
+
index: int
|
|
40
|
+
role: str
|
|
41
|
+
name: str
|
|
42
|
+
x: int = 0
|
|
43
|
+
y: int = 0
|
|
44
|
+
width: int = 0
|
|
45
|
+
height: int = 0
|
|
46
|
+
states: list = field(default_factory=list)
|
|
47
|
+
actions: list = field(default_factory=list)
|
|
48
|
+
editable: bool = False
|
|
49
|
+
# Opaque handle for acting on the node: (bus, path) for the D-Bus backend,
|
|
50
|
+
# a live pyatspi node for the pyatspi backend. Not part of the public shape.
|
|
51
|
+
handle: object = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Roles we consider "interactive" for SOM overlays.
|
|
55
|
+
_INTERACTIVE_ROLES = {
|
|
56
|
+
"push button", "button", "text", "entry", "text entry", "edit",
|
|
57
|
+
"check box", "check button", "radio button", "combo box", "spin button",
|
|
58
|
+
"slider", "link", "menu item", "list item", "tab", "page tab",
|
|
59
|
+
"toggle button", "icon", "image", "label", "table cell", "scroll bar",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _atspi_available() -> bool:
|
|
64
|
+
"""True if the legacy pyatspi module is importable."""
|
|
65
|
+
try:
|
|
66
|
+
import pyatspi # noqa: F401
|
|
67
|
+
return True
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_backend_cache = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _backend() -> Optional[str]:
|
|
76
|
+
"""Pick the AT-SPI backend: 'dbus' (preferred), 'pyatspi', or None."""
|
|
77
|
+
global _backend_cache
|
|
78
|
+
if _backend_cache is None:
|
|
79
|
+
if atspi_dbus.available():
|
|
80
|
+
_backend_cache = "dbus"
|
|
81
|
+
elif _atspi_available():
|
|
82
|
+
_backend_cache = "pyatspi"
|
|
83
|
+
else:
|
|
84
|
+
_backend_cache = None
|
|
85
|
+
return _backend_cache
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _el_to_dict(el: A11yElement) -> dict:
|
|
89
|
+
d = {
|
|
90
|
+
"index": el.index,
|
|
91
|
+
"role": el.role,
|
|
92
|
+
"name": el.name,
|
|
93
|
+
"x": el.x,
|
|
94
|
+
"y": el.y,
|
|
95
|
+
"width": el.width,
|
|
96
|
+
"height": el.height,
|
|
97
|
+
"center_x": el.x + el.width // 2,
|
|
98
|
+
"center_y": el.y + el.height // 2,
|
|
99
|
+
"states": el.states,
|
|
100
|
+
"actions": el.actions,
|
|
101
|
+
"editable": el.editable,
|
|
102
|
+
}
|
|
103
|
+
return d
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _collect_nodes(window_id: str, max_elements: int = 500,
|
|
107
|
+
only_interactive: bool = True):
|
|
108
|
+
"""Return (elements: list[(A11yElement, handle)], win) for a window.
|
|
109
|
+
|
|
110
|
+
Renders nothing publicly; raises RuntimeError if no AT-SPI backend is
|
|
111
|
+
available.
|
|
112
|
+
"""
|
|
113
|
+
be = _backend()
|
|
114
|
+
if be is None:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
"AT-SPI unavailable: neither the D-Bus backend (jeepney) nor "
|
|
117
|
+
"pyatspi is usable"
|
|
118
|
+
)
|
|
119
|
+
from .windows import get_window, is_uuid
|
|
120
|
+
if not is_uuid(window_id):
|
|
121
|
+
raise ValueError(f"not a valid KDE window UUID: {window_id!r}")
|
|
122
|
+
win = get_window(window_id)
|
|
123
|
+
|
|
124
|
+
if be == "dbus":
|
|
125
|
+
if not win.pid:
|
|
126
|
+
raise RuntimeError(
|
|
127
|
+
"window reports no PID; cannot match its AT-SPI tree"
|
|
128
|
+
)
|
|
129
|
+
children = atspi_dbus.elements_for_window(
|
|
130
|
+
win.pid, max_elements=max_elements, only_interactive=only_interactive)
|
|
131
|
+
out = []
|
|
132
|
+
for c in children:
|
|
133
|
+
handle = c.get("handle") # read-only: cached lists must not mutate
|
|
134
|
+
out.append((A11yElement(
|
|
135
|
+
index=c["index"], role=c["role"], name=c["name"],
|
|
136
|
+
x=c["x"], y=c["y"], width=c["width"], height=c["height"],
|
|
137
|
+
states=c["states"], actions=c["actions"], editable=c["editable"],
|
|
138
|
+
handle=handle,
|
|
139
|
+
), handle))
|
|
140
|
+
return out, win
|
|
141
|
+
|
|
142
|
+
# pyatspi backend.
|
|
143
|
+
import pyatspi
|
|
144
|
+
|
|
145
|
+
registry = pyatspi.Registry
|
|
146
|
+
desktop = registry.getDesktop(0)
|
|
147
|
+
pairs: list = []
|
|
148
|
+
idx = 0
|
|
149
|
+
|
|
150
|
+
def _state_flags(node) -> tuple[list, bool, list]:
|
|
151
|
+
states, editable, actions = [], False, []
|
|
152
|
+
try:
|
|
153
|
+
st = node.getState()
|
|
154
|
+
for flag, label in (
|
|
155
|
+
(pyatspi.STATE_CHECKED, "checked"),
|
|
156
|
+
(pyatspi.STATE_FOCUSED, "focused"),
|
|
157
|
+
(pyatspi.STATE_SELECTED, "selected"),
|
|
158
|
+
(pyatspi.STATE_SHOWING, "showing"),
|
|
159
|
+
(pyatspi.STATE_VISIBLE, "visible"),
|
|
160
|
+
(pyatspi.STATE_ENABLED, "enabled"),
|
|
161
|
+
(pyatspi.STATE_SENSITIVE, "sensitive"),
|
|
162
|
+
(pyatspi.STATE_EDITABLE, "editable"),
|
|
163
|
+
):
|
|
164
|
+
try:
|
|
165
|
+
if st.contains(flag):
|
|
166
|
+
states.append(label)
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
try:
|
|
172
|
+
atn = node.queryAction()
|
|
173
|
+
n = int(atn.nActions)
|
|
174
|
+
actions = [str(atn.getName(i) or "") for i in range(n)]
|
|
175
|
+
except Exception:
|
|
176
|
+
actions = []
|
|
177
|
+
try:
|
|
178
|
+
node.queryEditableText()
|
|
179
|
+
editable = True
|
|
180
|
+
except Exception:
|
|
181
|
+
editable = False
|
|
182
|
+
return states, editable, actions
|
|
183
|
+
|
|
184
|
+
def walk(node, depth=0):
|
|
185
|
+
nonlocal idx
|
|
186
|
+
if node is None or idx >= max_elements:
|
|
187
|
+
return
|
|
188
|
+
try:
|
|
189
|
+
role = str(node.getRoleName()).strip().lower()
|
|
190
|
+
name = str(node.name or "").strip()
|
|
191
|
+
x = y = w = h = 0
|
|
192
|
+
try:
|
|
193
|
+
comp = node.queryComponent()
|
|
194
|
+
ext = comp.getExtents(pyatspi.DESKTOP_coords)
|
|
195
|
+
x, y, w, h = int(ext.x), int(ext.y), int(ext.width), int(ext.height)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
states, editable, actions = _state_flags(node)
|
|
199
|
+
except Exception:
|
|
200
|
+
return
|
|
201
|
+
if not only_interactive or role in _INTERACTIVE_ROLES:
|
|
202
|
+
el = A11yElement(
|
|
203
|
+
index=idx, role=role, name=name,
|
|
204
|
+
x=x, y=y, width=w, height=h,
|
|
205
|
+
states=states, actions=actions, editable=editable, handle=node,
|
|
206
|
+
)
|
|
207
|
+
pairs.append((el, node))
|
|
208
|
+
idx += 1
|
|
209
|
+
if idx >= max_elements:
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
for i in range(node.childCount):
|
|
213
|
+
walk(node.getChildAtIndex(i), depth + 1)
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
target = None
|
|
218
|
+
for d in range(desktop.childCount):
|
|
219
|
+
app = desktop.getChildAtIndex(d)
|
|
220
|
+
try:
|
|
221
|
+
if app.name and (win.app_name and win.app_name.lower() in app.name.lower()
|
|
222
|
+
or (win.pid and app.getApplication().get_process_id() == win.pid)):
|
|
223
|
+
target = app
|
|
224
|
+
break
|
|
225
|
+
except Exception:
|
|
226
|
+
continue
|
|
227
|
+
if target is None and desktop.childCount:
|
|
228
|
+
target = desktop.getChildAtIndex(0)
|
|
229
|
+
if target is not None:
|
|
230
|
+
walk(target)
|
|
231
|
+
return pairs, win
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_window_state(window_id: str, max_elements: int = 100,
|
|
235
|
+
only_interactive: bool = True) -> dict:
|
|
236
|
+
"""Return the AT-SPI tree for the app owning a KDE window UUID.
|
|
237
|
+
|
|
238
|
+
Returns a dict with 'available' (bool), 'elements' (list of dicts with
|
|
239
|
+
index, role, name, bounds, state flags, actions, editable) and 'error' when
|
|
240
|
+
unavailable. When 'available' is False the caller should fall back to
|
|
241
|
+
coordinate-based clicks.
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
pairs, win = _collect_nodes(window_id, max_elements=max_elements,
|
|
245
|
+
only_interactive=only_interactive)
|
|
246
|
+
except (RuntimeError, ValueError) as exc:
|
|
247
|
+
return {
|
|
248
|
+
"available": False,
|
|
249
|
+
"elements": [],
|
|
250
|
+
"error": str(exc),
|
|
251
|
+
"window_id": window_id,
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
"available": True,
|
|
255
|
+
"window_id": window_id,
|
|
256
|
+
"window_title": win.title,
|
|
257
|
+
"elements": [_el_to_dict(el) for (el, _n) in pairs],
|
|
258
|
+
"count": len(pairs),
|
|
259
|
+
"backend": _backend(),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def resolve_elements(window_id: str, *, role: str = "", name: str = "",
|
|
264
|
+
text: str = "", max_elements: int = 500,
|
|
265
|
+
only_interactive: bool = True) -> list[A11yElement]:
|
|
266
|
+
"""Semantic search: return elements matching role / name / text filters.
|
|
267
|
+
|
|
268
|
+
All filters are case-insensitive substrings. ``text`` additionally matches
|
|
269
|
+
an element's name (buttons and text nodes expose their label as the name).
|
|
270
|
+
Returns a flat list ordered by depth-first document order. When no AT-SPI
|
|
271
|
+
backend is available this returns an empty list (so callers fall back to
|
|
272
|
+
coordinate input) rather than raising.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
pairs, _win = _collect_nodes(window_id, max_elements=max_elements,
|
|
276
|
+
only_interactive=only_interactive)
|
|
277
|
+
except (RuntimeError, ValueError):
|
|
278
|
+
return []
|
|
279
|
+
role = role.lower()
|
|
280
|
+
name = name.lower()
|
|
281
|
+
text = text.lower()
|
|
282
|
+
out = []
|
|
283
|
+
for el, _n in pairs:
|
|
284
|
+
r_ok = (not role) or role in el.role
|
|
285
|
+
n_ok = (not name) or (name in el.name.lower())
|
|
286
|
+
t_ok = (not text) or (text in el.name.lower())
|
|
287
|
+
if r_ok and n_ok and t_ok:
|
|
288
|
+
out.append(el)
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _node_by_index(window_id: str, element_index: int,
|
|
293
|
+
max_elements: int = 500):
|
|
294
|
+
# Use the SAME numbering as get_window_state (interactive elements only),
|
|
295
|
+
# so an index returned by get_window_state is valid here and in
|
|
296
|
+
# click_element / perform_action / set_value.
|
|
297
|
+
try:
|
|
298
|
+
pairs, _win = _collect_nodes(window_id, max_elements=max_elements,
|
|
299
|
+
only_interactive=True)
|
|
300
|
+
except (RuntimeError, ValueError):
|
|
301
|
+
return None, None
|
|
302
|
+
for el, handle in pairs:
|
|
303
|
+
if el.index == element_index:
|
|
304
|
+
return el, handle
|
|
305
|
+
return None, None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def click_element(window_id: str, element_index: int, max_elements: int = 100,
|
|
309
|
+
button: str = "left", double: bool = False) -> dict:
|
|
310
|
+
"""Resolve an AT-SPI element index to its center and click it."""
|
|
311
|
+
el, _handle = _node_by_index(window_id, element_index, max_elements=max_elements)
|
|
312
|
+
if el is None:
|
|
313
|
+
return {"ok": False, "error": f"element {element_index} not found"}
|
|
314
|
+
if el.width == 0 or el.height == 0:
|
|
315
|
+
return {"ok": False, "error": f"element {element_index} has no bounds"}
|
|
316
|
+
from .input import click_window
|
|
317
|
+
from .windows import get_window
|
|
318
|
+
win = get_window(window_id)
|
|
319
|
+
local_x = (el.x + el.width // 2) - win.x
|
|
320
|
+
local_y = (el.y + el.height // 2) - win.y
|
|
321
|
+
click_window(window_id, local_x, local_y, button=button, double=double)
|
|
322
|
+
return {"ok": True, "element": el.index,
|
|
323
|
+
"center_screen": [el.x + el.width // 2, el.y + el.height // 2]}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def click_semantic(window_id: str, *, role: str = "", name: str = "",
|
|
327
|
+
text: str = "", button: str = "left",
|
|
328
|
+
double: bool = False, max_elements: int = 500) -> dict:
|
|
329
|
+
"""Click the first element matching semantic role/name/text filters."""
|
|
330
|
+
matches = resolve_elements(window_id, role=role, name=name, text=text,
|
|
331
|
+
max_elements=max_elements)
|
|
332
|
+
if not matches:
|
|
333
|
+
return {"ok": False,
|
|
334
|
+
"error": f"no element matched role={role!r} name={name!r} text={text!r}"}
|
|
335
|
+
el = matches[0]
|
|
336
|
+
if el.width == 0 or el.height == 0:
|
|
337
|
+
return {"ok": False, "error": f"matched element {el.index} has no bounds"}
|
|
338
|
+
return click_element(window_id, el.index, button=button, double=double,
|
|
339
|
+
max_elements=max_elements)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def perform_action(window_id: str, element_index: int, action: str = "",
|
|
343
|
+
max_elements: int = 500) -> dict:
|
|
344
|
+
"""Invoke an AT-SPI action on an element (e.g. 'press', 'activate').
|
|
345
|
+
|
|
346
|
+
When ``action`` is empty the element's primary action (index 0) is used.
|
|
347
|
+
Requires an AT-SPI backend; returns an error dict when unavailable.
|
|
348
|
+
"""
|
|
349
|
+
el, handle = _node_by_index(window_id, element_index, max_elements=max_elements)
|
|
350
|
+
if el is None:
|
|
351
|
+
return {"ok": False, "error": f"element {element_index} not found"}
|
|
352
|
+
if isinstance(handle, tuple) and _backend() == "dbus":
|
|
353
|
+
ok, detail = atspi_dbus.perform_action(handle, action)
|
|
354
|
+
return {"ok": ok, "action": action or "primary",
|
|
355
|
+
"element": el.index, **( {"error": detail} if not ok else {})}
|
|
356
|
+
# pyatspi backend.
|
|
357
|
+
try:
|
|
358
|
+
atn = handle.queryAction()
|
|
359
|
+
n = int(atn.nActions)
|
|
360
|
+
if n == 0:
|
|
361
|
+
return {"ok": False, "error": f"element {element_index} exposes no actions"}
|
|
362
|
+
idx = -1
|
|
363
|
+
if action:
|
|
364
|
+
idx = next((i for i in range(n)
|
|
365
|
+
if atn.getName(i).strip().lower() == action.lower()), -1)
|
|
366
|
+
if idx == -1:
|
|
367
|
+
names = [str(atn.getName(i)) for i in range(n)]
|
|
368
|
+
return {"ok": False, "error": f"no action {action!r}; available: {names}"}
|
|
369
|
+
else:
|
|
370
|
+
idx = 0
|
|
371
|
+
name = str(atn.getName(idx))
|
|
372
|
+
ok = bool(atn.doAction(idx))
|
|
373
|
+
return {"ok": ok, "action": name, "element": el.index}
|
|
374
|
+
except Exception as exc: # noqa: BLE001
|
|
375
|
+
return {"ok": False, "error": str(exc)}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def set_value(window_id: str, element_index: int, value: str,
|
|
379
|
+
max_elements: int = 500) -> dict:
|
|
380
|
+
"""Write a value to a settable AT-SPI element (text field, slider, ...)."""
|
|
381
|
+
el, handle = _node_by_index(window_id, element_index, max_elements=max_elements)
|
|
382
|
+
if el is None:
|
|
383
|
+
return {"ok": False, "error": f"element {element_index} not found"}
|
|
384
|
+
if isinstance(handle, tuple) and _backend() == "dbus":
|
|
385
|
+
ok, detail = atspi_dbus.set_value(handle, value)
|
|
386
|
+
return {"ok": ok, "element": el.index, "value": str(value),
|
|
387
|
+
**( {"error": detail} if not ok else {})}
|
|
388
|
+
# pyatspi backend.
|
|
389
|
+
try:
|
|
390
|
+
et = handle.queryEditableText()
|
|
391
|
+
et.setTextContents(str(value))
|
|
392
|
+
return {"ok": True, "element": el.index, "value": str(value)}
|
|
393
|
+
except Exception as exc: # noqa: BLE001
|
|
394
|
+
return {"ok": False, "error": f"element {element_index} not settable: {exc}"}
|