pyqt-runtime-mcp 0.1.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.
- pyqt_runtime_mcp/__init__.py +18 -0
- pyqt_runtime_mcp/__main__.py +6 -0
- pyqt_runtime_mcp/bridge/__init__.py +5 -0
- pyqt_runtime_mcp/bridge/bridge.py +381 -0
- pyqt_runtime_mcp/bridge/diagnostics.py +248 -0
- pyqt_runtime_mcp/bridge/dispatcher.py +78 -0
- pyqt_runtime_mcp/bridge/inspectors.py +352 -0
- pyqt_runtime_mcp/bridge/interaction.py +348 -0
- pyqt_runtime_mcp/bridge/logging.py +219 -0
- pyqt_runtime_mcp/bridge/overlay.py +124 -0
- pyqt_runtime_mcp/bridge/properties.py +113 -0
- pyqt_runtime_mcp/bridge/qtutil.py +116 -0
- pyqt_runtime_mcp/bridge/registry.py +235 -0
- pyqt_runtime_mcp/bridge/router.py +92 -0
- pyqt_runtime_mcp/bridge/screenshots.py +225 -0
- pyqt_runtime_mcp/bridge/signals.py +119 -0
- pyqt_runtime_mcp/bridge/snapshot.py +138 -0
- pyqt_runtime_mcp/bridge/styles.py +65 -0
- pyqt_runtime_mcp/bridge/widgets.py +278 -0
- pyqt_runtime_mcp/bridge/windows.py +273 -0
- pyqt_runtime_mcp/protocol/__init__.py +6 -0
- pyqt_runtime_mcp/protocol/client.py +211 -0
- pyqt_runtime_mcp/protocol/errors.py +49 -0
- pyqt_runtime_mcp/protocol/messages.py +103 -0
- pyqt_runtime_mcp/protocol/transport.py +80 -0
- pyqt_runtime_mcp/server.py +57 -0
- pyqt_runtime_mcp/tools/__init__.py +12 -0
- pyqt_runtime_mcp/tools/application.py +99 -0
- pyqt_runtime_mcp/tools/common.py +122 -0
- pyqt_runtime_mcp/tools/diagnostics.py +95 -0
- pyqt_runtime_mcp/tools/interaction.py +160 -0
- pyqt_runtime_mcp/tools/screenshots.py +123 -0
- pyqt_runtime_mcp/tools/styles.py +42 -0
- pyqt_runtime_mcp/tools/widgets.py +87 -0
- pyqt_runtime_mcp-0.1.0.dist-info/METADATA +297 -0
- pyqt_runtime_mcp-0.1.0.dist-info/RECORD +40 -0
- pyqt_runtime_mcp-0.1.0.dist-info/WHEEL +5 -0
- pyqt_runtime_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- pyqt_runtime_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyqt_runtime_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Reusable PyQt5 runtime MCP bridge and server.
|
|
2
|
+
|
|
3
|
+
Importing this package does not import PyQt5. The in-app bridge is loaded
|
|
4
|
+
lazily so the Cursor MCP stdio process can run without Qt.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = ["PyQtMCPBridge", "install_pyqt_mcp"]
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str) -> Any:
|
|
14
|
+
if name in {"PyQtMCPBridge", "install_pyqt_mcp"}:
|
|
15
|
+
from pyqt_runtime_mcp.bridge.bridge import PyQtMCPBridge, install_pyqt_mcp
|
|
16
|
+
|
|
17
|
+
return PyQtMCPBridge if name == "PyQtMCPBridge" else install_pyqt_mcp
|
|
18
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""In-app PyQt5 runtime bridge: TCP server + GUI dispatcher + widget registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from PyQt5.QtCore import QObject
|
|
14
|
+
from PyQt5.QtWidgets import QApplication
|
|
15
|
+
|
|
16
|
+
from pyqt_runtime_mcp.bridge.dispatcher import GuiDispatcher
|
|
17
|
+
from pyqt_runtime_mcp.bridge.logging import (
|
|
18
|
+
ExceptionCapture,
|
|
19
|
+
PythonLogCapture,
|
|
20
|
+
QtLogCapture,
|
|
21
|
+
RingBuffer,
|
|
22
|
+
)
|
|
23
|
+
from pyqt_runtime_mcp.bridge.registry import WidgetRegistry
|
|
24
|
+
from pyqt_runtime_mcp.bridge.router import register_handlers
|
|
25
|
+
from pyqt_runtime_mcp.protocol.errors import BridgeError
|
|
26
|
+
from pyqt_runtime_mcp.protocol.messages import (
|
|
27
|
+
DEFAULT_HOST,
|
|
28
|
+
DEFAULT_PORT,
|
|
29
|
+
PROTOCOL_NAME,
|
|
30
|
+
Handshake,
|
|
31
|
+
Request,
|
|
32
|
+
Response,
|
|
33
|
+
)
|
|
34
|
+
from pyqt_runtime_mcp.protocol.transport import (
|
|
35
|
+
StreamClosed,
|
|
36
|
+
StreamCorrupted,
|
|
37
|
+
discovery_path,
|
|
38
|
+
recv_json,
|
|
39
|
+
send_json,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_LOG = logging.getLogger("pyqt_runtime_mcp.bridge")
|
|
43
|
+
_INSTALLED: Optional["PyQtMCPBridge"] = None
|
|
44
|
+
SCREENSHOT_TIMEOUT = 15.0
|
|
45
|
+
DEFAULT_TIMEOUT = 10.0
|
|
46
|
+
MAX_TIMEOUT = 120.0
|
|
47
|
+
# Answer a little before the client's own deadline, so a slow handler produces a
|
|
48
|
+
# TIMEOUT reply on this connection instead of an unread frame the client would
|
|
49
|
+
# later mistake for the next response.
|
|
50
|
+
DEADLINE_MARGIN = 0.5
|
|
51
|
+
HEAVY_METHODS = frozenset(
|
|
52
|
+
{
|
|
53
|
+
"get_widget_tree",
|
|
54
|
+
"find_widgets",
|
|
55
|
+
"get_ui_snapshot",
|
|
56
|
+
"visual_snapshot",
|
|
57
|
+
"analyze_layout",
|
|
58
|
+
"capture_window",
|
|
59
|
+
"capture_widget",
|
|
60
|
+
"capture_region",
|
|
61
|
+
"capture_screen_region",
|
|
62
|
+
"capture_sections",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
# Poll interval while idle; connections are kept open across it.
|
|
66
|
+
IDLE_POLL = 1.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BridgeContext:
|
|
70
|
+
__slots__ = (
|
|
71
|
+
"app",
|
|
72
|
+
"registry",
|
|
73
|
+
"screenshot_dir",
|
|
74
|
+
"logs",
|
|
75
|
+
"exceptions",
|
|
76
|
+
"events",
|
|
77
|
+
"signal_events",
|
|
78
|
+
"signal_watches",
|
|
79
|
+
"overlay",
|
|
80
|
+
"token",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def __init__(self, app: QApplication, screenshot_dir: Path, token: Optional[str]) -> None:
|
|
84
|
+
self.app = app
|
|
85
|
+
self.registry = WidgetRegistry()
|
|
86
|
+
self.screenshot_dir = screenshot_dir
|
|
87
|
+
self.logs = RingBuffer()
|
|
88
|
+
self.exceptions = RingBuffer()
|
|
89
|
+
self.events = RingBuffer()
|
|
90
|
+
self.signal_events = RingBuffer()
|
|
91
|
+
self.signal_watches: dict[str, list] = {}
|
|
92
|
+
self.overlay = None
|
|
93
|
+
self.token = token
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PyQtMCPBridge(QObject):
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
app: QApplication,
|
|
100
|
+
host: str = DEFAULT_HOST,
|
|
101
|
+
port: int = DEFAULT_PORT,
|
|
102
|
+
token: Optional[str] = None,
|
|
103
|
+
screenshot_dir: Optional[str] = None,
|
|
104
|
+
parent: Optional[QObject] = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__(parent if parent is not None else app)
|
|
107
|
+
if app is None:
|
|
108
|
+
raise BridgeError("APPLICATION_NOT_CONNECTED", "QApplication is required")
|
|
109
|
+
if host not in {"127.0.0.1", "localhost", "::1"}:
|
|
110
|
+
raise BridgeError("INVALID_ARGUMENT", "Bridge must bind to localhost")
|
|
111
|
+
self._app = app
|
|
112
|
+
self._host = "127.0.0.1" if host == "localhost" else host
|
|
113
|
+
self._requested_port = int(port)
|
|
114
|
+
self._port = int(port)
|
|
115
|
+
self._token = token or os.environ.get("PYQT_MCP_TOKEN") or None
|
|
116
|
+
sandbox = screenshot_dir or os.environ.get("PYQT_MCP_SCREENSHOT_DIR")
|
|
117
|
+
if sandbox:
|
|
118
|
+
self._screenshot_dir = Path(sandbox)
|
|
119
|
+
else:
|
|
120
|
+
self._screenshot_dir = discovery_path().parent / "screenshots"
|
|
121
|
+
self._screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
self._ctx = BridgeContext(app, self._screenshot_dir, self._token)
|
|
123
|
+
self._dispatcher = GuiDispatcher(parent=self)
|
|
124
|
+
register_handlers(self._dispatcher, self._ctx)
|
|
125
|
+
self._qt_logs = QtLogCapture(self._ctx.logs)
|
|
126
|
+
self._py_logs = PythonLogCapture(self._ctx.logs)
|
|
127
|
+
self._exc = ExceptionCapture(self._ctx.exceptions)
|
|
128
|
+
self._sock: Optional[socket.socket] = None
|
|
129
|
+
self._thread: Optional[threading.Thread] = None
|
|
130
|
+
self._stop = threading.Event()
|
|
131
|
+
self._clients: list[socket.socket] = []
|
|
132
|
+
self._clients_lock = threading.Lock()
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def port(self) -> int:
|
|
136
|
+
return self._port
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def host(self) -> str:
|
|
140
|
+
return self._host
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def context(self) -> BridgeContext:
|
|
144
|
+
return self._ctx
|
|
145
|
+
|
|
146
|
+
def start(self) -> "PyQtMCPBridge":
|
|
147
|
+
if self._thread is not None:
|
|
148
|
+
return self
|
|
149
|
+
self._qt_logs.install()
|
|
150
|
+
self._py_logs.install()
|
|
151
|
+
self._exc.install()
|
|
152
|
+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
153
|
+
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
154
|
+
bound = False
|
|
155
|
+
last_err: Optional[OSError] = None
|
|
156
|
+
start_port = self._requested_port
|
|
157
|
+
attempts = [0] if start_port == 0 else list(range(start_port, start_port + 21))
|
|
158
|
+
for candidate in attempts:
|
|
159
|
+
try:
|
|
160
|
+
self._sock.bind((self._host, candidate))
|
|
161
|
+
self._port = int(self._sock.getsockname()[1])
|
|
162
|
+
bound = True
|
|
163
|
+
break
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
last_err = exc
|
|
166
|
+
continue
|
|
167
|
+
if not bound:
|
|
168
|
+
raise BridgeError(
|
|
169
|
+
"INVALID_ARGUMENT",
|
|
170
|
+
f"Could not bind {self._host}:{start_port}: {last_err}",
|
|
171
|
+
)
|
|
172
|
+
self._sock.listen(8)
|
|
173
|
+
self._sock.settimeout(0.5)
|
|
174
|
+
self._write_discovery()
|
|
175
|
+
self._stop.clear()
|
|
176
|
+
self._thread = threading.Thread(target=self._accept_loop, name="pyqt-mcp-bridge", daemon=True)
|
|
177
|
+
self._thread.start()
|
|
178
|
+
_LOG.info("PyQt MCP bridge listening on %s:%s", self._host, self._port)
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def stop(self) -> None:
|
|
182
|
+
self._stop.set()
|
|
183
|
+
with self._clients_lock:
|
|
184
|
+
clients = list(self._clients)
|
|
185
|
+
self._clients.clear()
|
|
186
|
+
for client in clients:
|
|
187
|
+
try:
|
|
188
|
+
client.close()
|
|
189
|
+
except OSError:
|
|
190
|
+
pass
|
|
191
|
+
if self._sock is not None:
|
|
192
|
+
try:
|
|
193
|
+
self._sock.close()
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
self._sock = None
|
|
197
|
+
if self._thread is not None and self._thread.is_alive() and threading.current_thread() is not self._thread:
|
|
198
|
+
self._thread.join(timeout=2.0)
|
|
199
|
+
self._thread = None
|
|
200
|
+
self._qt_logs.uninstall()
|
|
201
|
+
self._py_logs.uninstall()
|
|
202
|
+
self._exc.uninstall()
|
|
203
|
+
from pyqt_runtime_mcp.bridge.overlay import hide_overlay
|
|
204
|
+
|
|
205
|
+
hide_overlay(self._ctx)
|
|
206
|
+
_remove_discovery()
|
|
207
|
+
global _INSTALLED
|
|
208
|
+
if _INSTALLED is self:
|
|
209
|
+
_INSTALLED = None
|
|
210
|
+
|
|
211
|
+
def _write_discovery(self) -> None:
|
|
212
|
+
path = discovery_path()
|
|
213
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
payload = {
|
|
215
|
+
"protocol": PROTOCOL_NAME,
|
|
216
|
+
"host": self._host,
|
|
217
|
+
"port": self._port,
|
|
218
|
+
"pid": os.getpid(),
|
|
219
|
+
"token_required": bool(self._token),
|
|
220
|
+
}
|
|
221
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
222
|
+
|
|
223
|
+
def _accept_loop(self) -> None:
|
|
224
|
+
while not self._stop.is_set():
|
|
225
|
+
sock = self._sock
|
|
226
|
+
if sock is None:
|
|
227
|
+
break
|
|
228
|
+
try:
|
|
229
|
+
client, _addr = sock.accept()
|
|
230
|
+
except socket.timeout:
|
|
231
|
+
continue
|
|
232
|
+
except OSError:
|
|
233
|
+
if self._stop.is_set():
|
|
234
|
+
break
|
|
235
|
+
continue
|
|
236
|
+
worker = threading.Thread(target=self._client_loop, args=(client,), daemon=True)
|
|
237
|
+
worker.start()
|
|
238
|
+
|
|
239
|
+
def _client_loop(self, client: socket.socket) -> None:
|
|
240
|
+
with self._clients_lock:
|
|
241
|
+
self._clients.append(client)
|
|
242
|
+
try:
|
|
243
|
+
client.settimeout(30.0)
|
|
244
|
+
raw = recv_json(client)
|
|
245
|
+
try:
|
|
246
|
+
handshake = Handshake.from_dict(raw)
|
|
247
|
+
except BridgeError as exc:
|
|
248
|
+
send_json(client, {"ok": False, "error": exc.to_dict()})
|
|
249
|
+
return
|
|
250
|
+
if handshake.protocol != PROTOCOL_NAME:
|
|
251
|
+
send_json(
|
|
252
|
+
client,
|
|
253
|
+
{
|
|
254
|
+
"ok": False,
|
|
255
|
+
"error": {
|
|
256
|
+
"code": "PROTOCOL_ERROR",
|
|
257
|
+
"message": f"Unsupported protocol {handshake.protocol!r}",
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
if self._token and handshake.token != self._token:
|
|
263
|
+
send_json(
|
|
264
|
+
client,
|
|
265
|
+
{
|
|
266
|
+
"ok": False,
|
|
267
|
+
"error": {
|
|
268
|
+
"code": "AUTHENTICATION_FAILED",
|
|
269
|
+
"message": "Invalid or missing token",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
return
|
|
274
|
+
send_json(client, {"ok": True, "protocol": PROTOCOL_NAME, "port": self._port})
|
|
275
|
+
client.settimeout(IDLE_POLL)
|
|
276
|
+
while not self._stop.is_set():
|
|
277
|
+
try:
|
|
278
|
+
message = recv_json(client)
|
|
279
|
+
except socket.timeout:
|
|
280
|
+
continue # idle connections stay open
|
|
281
|
+
except StreamClosed:
|
|
282
|
+
break
|
|
283
|
+
except StreamCorrupted as exc:
|
|
284
|
+
# Framing cannot be resynchronised; report once and hang up.
|
|
285
|
+
_LOG.warning("Dropping client after framing error: %s", exc.message)
|
|
286
|
+
self._send(client, Response(id="", ok=False, error=exc.to_dict()).to_dict())
|
|
287
|
+
break
|
|
288
|
+
except BridgeError as exc:
|
|
289
|
+
if not self._send(client, Response(id="", ok=False, error=exc.to_dict()).to_dict()):
|
|
290
|
+
break
|
|
291
|
+
continue
|
|
292
|
+
except OSError:
|
|
293
|
+
break
|
|
294
|
+
if not self._handle_request(client, message):
|
|
295
|
+
break
|
|
296
|
+
except (StreamClosed, OSError):
|
|
297
|
+
pass
|
|
298
|
+
except BridgeError as exc:
|
|
299
|
+
_LOG.warning("Bridge client aborted: %s", exc.message)
|
|
300
|
+
finally:
|
|
301
|
+
try:
|
|
302
|
+
client.close()
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
with self._clients_lock:
|
|
306
|
+
if client in self._clients:
|
|
307
|
+
self._clients.remove(client)
|
|
308
|
+
|
|
309
|
+
def _handle_request(self, client: socket.socket, message: Any) -> bool:
|
|
310
|
+
"""Serve one request. Returns False when the connection must be closed."""
|
|
311
|
+
try:
|
|
312
|
+
request = Request.from_dict(message)
|
|
313
|
+
except BridgeError as exc:
|
|
314
|
+
return self._send(client, Response(id="", ok=False, error=exc.to_dict()).to_dict())
|
|
315
|
+
try:
|
|
316
|
+
result = self._dispatcher.submit(
|
|
317
|
+
request.method, request.params, timeout=_gui_timeout(request)
|
|
318
|
+
)
|
|
319
|
+
response = Response(id=request.id, ok=True, result=result)
|
|
320
|
+
except BridgeError as exc:
|
|
321
|
+
response = Response(id=request.id, ok=False, error=exc.to_dict())
|
|
322
|
+
except Exception as exc: # never let one bad handler kill the connection
|
|
323
|
+
_LOG.exception("Unhandled error in %s", request.method)
|
|
324
|
+
error = BridgeError("GUI_THREAD_ERROR", f"{type(exc).__name__}: {exc}")
|
|
325
|
+
response = Response(id=request.id, ok=False, error=error.to_dict())
|
|
326
|
+
payload = response.to_dict()
|
|
327
|
+
try:
|
|
328
|
+
return self._send(client, payload)
|
|
329
|
+
except BridgeError as exc:
|
|
330
|
+
# Result could not be encoded (too large, or not JSON-serialisable).
|
|
331
|
+
return self._send(client, Response(id=request.id, ok=False, error=exc.to_dict()).to_dict())
|
|
332
|
+
|
|
333
|
+
def _send(self, client: socket.socket, payload: dict[str, Any]) -> bool:
|
|
334
|
+
try:
|
|
335
|
+
send_json(client, payload)
|
|
336
|
+
except OSError:
|
|
337
|
+
return False
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _gui_timeout(request: Request) -> float:
|
|
342
|
+
"""GUI budget for one request, kept just inside the client's own deadline."""
|
|
343
|
+
default = SCREENSHOT_TIMEOUT if request.method in HEAVY_METHODS else DEFAULT_TIMEOUT
|
|
344
|
+
budget = request.timeout if request.timeout is not None else default
|
|
345
|
+
extra = request.params.get("timeout")
|
|
346
|
+
if isinstance(extra, (int, float)) and extra > 0:
|
|
347
|
+
budget = float(extra)
|
|
348
|
+
budget = min(float(budget), MAX_TIMEOUT)
|
|
349
|
+
return max(budget - DEADLINE_MARGIN, 0.5)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def install_pyqt_mcp(
|
|
353
|
+
app: QApplication,
|
|
354
|
+
host: str = DEFAULT_HOST,
|
|
355
|
+
port: int = DEFAULT_PORT,
|
|
356
|
+
token: Optional[str] = None,
|
|
357
|
+
screenshot_dir: Optional[str] = None,
|
|
358
|
+
) -> PyQtMCPBridge:
|
|
359
|
+
"""Start the in-process runtime bridge. Bind is localhost-only."""
|
|
360
|
+
global _INSTALLED
|
|
361
|
+
if _INSTALLED is not None:
|
|
362
|
+
return _INSTALLED
|
|
363
|
+
bridge = PyQtMCPBridge(
|
|
364
|
+
app,
|
|
365
|
+
host=host,
|
|
366
|
+
port=port,
|
|
367
|
+
token=token,
|
|
368
|
+
screenshot_dir=screenshot_dir,
|
|
369
|
+
)
|
|
370
|
+
bridge.start()
|
|
371
|
+
app.aboutToQuit.connect(bridge.stop)
|
|
372
|
+
_INSTALLED = bridge
|
|
373
|
+
return bridge
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _remove_discovery() -> None:
|
|
377
|
+
path = discovery_path()
|
|
378
|
+
try:
|
|
379
|
+
path.unlink()
|
|
380
|
+
except OSError:
|
|
381
|
+
pass
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Heuristic layout diagnostics. Unusual is not always a bug."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from PyQt5.QtWidgets import (
|
|
9
|
+
QAbstractScrollArea,
|
|
10
|
+
QAbstractSpinBox,
|
|
11
|
+
QComboBox,
|
|
12
|
+
QHeaderView,
|
|
13
|
+
QMenu,
|
|
14
|
+
QMenuBar,
|
|
15
|
+
QSizePolicy,
|
|
16
|
+
QTabBar,
|
|
17
|
+
QToolBar,
|
|
18
|
+
QWidget,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from pyqt_runtime_mcp.bridge.qtutil import is_deleted, widget_class_name
|
|
22
|
+
from pyqt_runtime_mcp.bridge.registry import iter_widgets
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from pyqt_runtime_mcp.bridge.bridge import BridgeContext
|
|
26
|
+
|
|
27
|
+
HUGE_MIN = 2000
|
|
28
|
+
MIN_HINT_SLACK = 8
|
|
29
|
+
|
|
30
|
+
# Widgets that position their own children without a QLayout. Reporting a
|
|
31
|
+
# "missing layout" on these is always a false positive.
|
|
32
|
+
SELF_MANAGED = (
|
|
33
|
+
QAbstractScrollArea,
|
|
34
|
+
QAbstractSpinBox,
|
|
35
|
+
QComboBox,
|
|
36
|
+
QHeaderView,
|
|
37
|
+
QMenu,
|
|
38
|
+
QMenuBar,
|
|
39
|
+
QTabBar,
|
|
40
|
+
QToolBar,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def analyze_layout(ctx: "BridgeContext", params: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
root_spec = params.get("widget_id") or params.get("root")
|
|
46
|
+
root = ctx.registry.resolve_scoped(str(root_spec), params.get("window")) if root_spec else None
|
|
47
|
+
findings: list[dict[str, Any]] = []
|
|
48
|
+
names: Counter[str] = Counter()
|
|
49
|
+
widgets = list(iter_widgets(root))
|
|
50
|
+
for widget in widgets:
|
|
51
|
+
if is_deleted(widget):
|
|
52
|
+
continue
|
|
53
|
+
rid = ctx.registry.ensure(widget)
|
|
54
|
+
name = widget.objectName() or ""
|
|
55
|
+
if name and widget.isVisible() and not _is_internal(widget):
|
|
56
|
+
# Only visible duplicates matter: they are what makes a name-based
|
|
57
|
+
# lookup ambiguous. Hidden dialogs legitimately reuse names.
|
|
58
|
+
names[name] += 1
|
|
59
|
+
_outside_parent(findings, widget, rid)
|
|
60
|
+
_zero_size(findings, widget, rid)
|
|
61
|
+
_clipped(findings, widget, rid)
|
|
62
|
+
_below_min_hint(findings, widget, rid)
|
|
63
|
+
_minmax(findings, widget, rid)
|
|
64
|
+
_fixed_size(findings, widget, rid)
|
|
65
|
+
_invisible_in_layout(findings, widget, rid)
|
|
66
|
+
_missing_layout(findings, widget, rid)
|
|
67
|
+
_overlapping_siblings(ctx, findings, widgets)
|
|
68
|
+
for name, count in names.items():
|
|
69
|
+
if count > 1:
|
|
70
|
+
findings.append(
|
|
71
|
+
{
|
|
72
|
+
"severity": "info",
|
|
73
|
+
"widget": None,
|
|
74
|
+
"message": (
|
|
75
|
+
f"Duplicate objectName {name!r} appears on {count} visible widgets; "
|
|
76
|
+
"scope lookups with window=<window id>"
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
return {"findings": findings, "widget_count": len(widgets)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _finding(severity: str, rid: str, message: str) -> dict[str, Any]:
|
|
84
|
+
return {"severity": severity, "widget": rid, "message": message}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_internal(widget: QWidget) -> bool:
|
|
88
|
+
"""Qt names its own helper children (viewports, scrollbars, popups) qt_*."""
|
|
89
|
+
return (widget.objectName() or "").startswith("qt_")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _below_min_hint(findings: list, widget: QWidget, rid: str) -> None:
|
|
93
|
+
"""Rendered smaller than Qt says it needs, i.e. its content is clipped."""
|
|
94
|
+
if not widget.isVisible() or widget.isWindow() or _is_internal(widget):
|
|
95
|
+
return
|
|
96
|
+
policy = widget.sizePolicy()
|
|
97
|
+
if QSizePolicy.Ignored in (policy.horizontalPolicy(), policy.verticalPolicy()):
|
|
98
|
+
return
|
|
99
|
+
hint = widget.minimumSizeHint()
|
|
100
|
+
short_w = hint.width() - widget.width() if hint.width() > 0 else 0
|
|
101
|
+
short_h = hint.height() - widget.height() if hint.height() > 0 else 0
|
|
102
|
+
if max(short_w, short_h) <= MIN_HINT_SLACK:
|
|
103
|
+
return
|
|
104
|
+
parts = []
|
|
105
|
+
if short_w > MIN_HINT_SLACK:
|
|
106
|
+
parts.append(f"{short_w} px narrower")
|
|
107
|
+
if short_h > MIN_HINT_SLACK:
|
|
108
|
+
parts.append(f"{short_h} px shorter")
|
|
109
|
+
findings.append(
|
|
110
|
+
_finding(
|
|
111
|
+
"warning",
|
|
112
|
+
rid,
|
|
113
|
+
f"Rendered {' and '.join(parts)} than its minimumSizeHint "
|
|
114
|
+
f"({hint.width()}x{hint.height()}); content is likely clipped",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _outside_parent(findings: list, widget: QWidget, rid: str) -> None:
|
|
120
|
+
parent = widget.parentWidget()
|
|
121
|
+
if parent is None or not widget.isVisible() or widget.isWindow():
|
|
122
|
+
# A window's geometry is in screen coordinates; its Qt parent only owns
|
|
123
|
+
# its lifetime, so comparing the two rectangles is meaningless.
|
|
124
|
+
return
|
|
125
|
+
geo = widget.geometry()
|
|
126
|
+
overflow_x = max(0, -geo.x()) + max(0, geo.right() - parent.width() + 1)
|
|
127
|
+
overflow_y = max(0, -geo.y()) + max(0, geo.bottom() - parent.height() + 1)
|
|
128
|
+
overflow = max(overflow_x, overflow_y)
|
|
129
|
+
if overflow > 2:
|
|
130
|
+
findings.append(_finding("warning", rid, f"Widget extends {overflow} px outside parent bounds"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _zero_size(findings: list, widget: QWidget, rid: str) -> None:
|
|
134
|
+
if widget.isVisible() and (widget.width() <= 0 or widget.height() <= 0):
|
|
135
|
+
findings.append(_finding("warning", rid, "Visible widget has zero width or height"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _clipped(findings: list, widget: QWidget, rid: str) -> None:
|
|
139
|
+
parent = widget.parentWidget()
|
|
140
|
+
if parent is None or not widget.isVisible() or widget.isWindow():
|
|
141
|
+
return
|
|
142
|
+
geo = widget.geometry()
|
|
143
|
+
if geo.x() < 0 or geo.y() < 0:
|
|
144
|
+
findings.append(_finding("warning", rid, "Widget is clipped by its parent (negative origin)"))
|
|
145
|
+
return
|
|
146
|
+
if geo.right() > parent.width() + 1 or geo.bottom() > parent.height() + 1:
|
|
147
|
+
findings.append(_finding("info", rid, "Widget may be clipped by parent bounds"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _minmax(findings: list, widget: QWidget, rid: str) -> None:
|
|
151
|
+
mn = widget.minimumSize()
|
|
152
|
+
mx = widget.maximumSize()
|
|
153
|
+
if mn.width() > mx.width() or mn.height() > mx.height():
|
|
154
|
+
findings.append(_finding("error", rid, "Minimum size is larger than maximum size"))
|
|
155
|
+
if mn.width() >= HUGE_MIN or mn.height() >= HUGE_MIN:
|
|
156
|
+
findings.append(
|
|
157
|
+
_finding("warning", rid, f"Extremely large minimum size {mn.width()}x{mn.height()}")
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _fixed_size(findings: list, widget: QWidget, rid: str) -> None:
|
|
162
|
+
sp = widget.sizePolicy()
|
|
163
|
+
if sp.horizontalPolicy() == QSizePolicy.Fixed and widget.minimumWidth() == widget.maximumWidth() and widget.width() > 0:
|
|
164
|
+
if widget.maximumWidth() > 800:
|
|
165
|
+
findings.append(_finding("info", rid, "Suspicious large fixed width"))
|
|
166
|
+
if sp.verticalPolicy() == QSizePolicy.Fixed and widget.minimumHeight() == widget.maximumHeight() and widget.height() > 400:
|
|
167
|
+
findings.append(_finding("info", rid, "Suspicious large fixed height"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _invisible_in_layout(findings: list, widget: QWidget, rid: str) -> None:
|
|
171
|
+
"""Flag only when a non-hidden widget is invisible yet still a layout item.
|
|
172
|
+
|
|
173
|
+
Qt layouts already skip ``isHidden()`` children (capability gates, inactive
|
|
174
|
+
stack/tab pages), so those must not be reported as occupying space.
|
|
175
|
+
"""
|
|
176
|
+
parent = widget.parentWidget()
|
|
177
|
+
if parent is None or widget.isVisibleTo(parent):
|
|
178
|
+
return
|
|
179
|
+
# Hidden widgets / inactive pages: QWidgetItem.isEmpty() → no layout space.
|
|
180
|
+
if widget.isHidden():
|
|
181
|
+
return
|
|
182
|
+
ancestor = parent
|
|
183
|
+
while ancestor is not None:
|
|
184
|
+
if ancestor.isHidden():
|
|
185
|
+
return
|
|
186
|
+
ancestor = ancestor.parentWidget()
|
|
187
|
+
layout = parent.layout()
|
|
188
|
+
if layout is None:
|
|
189
|
+
return
|
|
190
|
+
for i in range(layout.count()):
|
|
191
|
+
item = layout.itemAt(i)
|
|
192
|
+
if item is not None and item.widget() is widget:
|
|
193
|
+
# Still empty ⇒ layout is not allocating space; skip noise.
|
|
194
|
+
if item.isEmpty():
|
|
195
|
+
return
|
|
196
|
+
findings.append(
|
|
197
|
+
_finding("info", rid, "Invisible widget still occupies layout space")
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _missing_layout(findings: list, widget: QWidget, rid: str) -> None:
|
|
203
|
+
if widget.layout() is not None or isinstance(widget, SELF_MANAGED) or _is_internal(widget):
|
|
204
|
+
return
|
|
205
|
+
direct = [
|
|
206
|
+
c
|
|
207
|
+
for c in widget.findChildren(QWidget)
|
|
208
|
+
if c.parentWidget() is widget and not _is_internal(c)
|
|
209
|
+
]
|
|
210
|
+
if len(direct) >= 4:
|
|
211
|
+
findings.append(
|
|
212
|
+
_finding(
|
|
213
|
+
"warning",
|
|
214
|
+
rid,
|
|
215
|
+
f"Container has {len(direct)} direct children but no QLayout",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _overlapping_siblings(ctx: "BridgeContext", findings: list, widgets: list[QWidget]) -> None:
|
|
221
|
+
by_parent: dict[int, list[QWidget]] = {}
|
|
222
|
+
for widget in widgets:
|
|
223
|
+
parent = widget.parentWidget()
|
|
224
|
+
if parent is None or widget.isWindow():
|
|
225
|
+
continue # separate windows are not siblings laid out in a parent
|
|
226
|
+
by_parent.setdefault(id(parent), []).append(widget)
|
|
227
|
+
reported = 0
|
|
228
|
+
for siblings in by_parent.values():
|
|
229
|
+
visible = [w for w in siblings if w.isVisible() and w.width() > 1 and w.height() > 1]
|
|
230
|
+
for i, a in enumerate(visible):
|
|
231
|
+
for b in visible[i + 1 :]:
|
|
232
|
+
if a.geometry().intersects(b.geometry()):
|
|
233
|
+
inter = a.geometry().intersected(b.geometry())
|
|
234
|
+
if inter.width() < 4 or inter.height() < 4:
|
|
235
|
+
continue
|
|
236
|
+
findings.append(
|
|
237
|
+
{
|
|
238
|
+
"severity": "warning",
|
|
239
|
+
"widget": ctx.registry.ensure(a),
|
|
240
|
+
"message": (
|
|
241
|
+
f"Overlaps sibling {ctx.registry.ensure(b)} "
|
|
242
|
+
f"by {inter.width()}x{inter.height()} px"
|
|
243
|
+
),
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
reported += 1
|
|
247
|
+
if reported >= 40:
|
|
248
|
+
return
|