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.
Files changed (40) hide show
  1. pyqt_runtime_mcp/__init__.py +18 -0
  2. pyqt_runtime_mcp/__main__.py +6 -0
  3. pyqt_runtime_mcp/bridge/__init__.py +5 -0
  4. pyqt_runtime_mcp/bridge/bridge.py +381 -0
  5. pyqt_runtime_mcp/bridge/diagnostics.py +248 -0
  6. pyqt_runtime_mcp/bridge/dispatcher.py +78 -0
  7. pyqt_runtime_mcp/bridge/inspectors.py +352 -0
  8. pyqt_runtime_mcp/bridge/interaction.py +348 -0
  9. pyqt_runtime_mcp/bridge/logging.py +219 -0
  10. pyqt_runtime_mcp/bridge/overlay.py +124 -0
  11. pyqt_runtime_mcp/bridge/properties.py +113 -0
  12. pyqt_runtime_mcp/bridge/qtutil.py +116 -0
  13. pyqt_runtime_mcp/bridge/registry.py +235 -0
  14. pyqt_runtime_mcp/bridge/router.py +92 -0
  15. pyqt_runtime_mcp/bridge/screenshots.py +225 -0
  16. pyqt_runtime_mcp/bridge/signals.py +119 -0
  17. pyqt_runtime_mcp/bridge/snapshot.py +138 -0
  18. pyqt_runtime_mcp/bridge/styles.py +65 -0
  19. pyqt_runtime_mcp/bridge/widgets.py +278 -0
  20. pyqt_runtime_mcp/bridge/windows.py +273 -0
  21. pyqt_runtime_mcp/protocol/__init__.py +6 -0
  22. pyqt_runtime_mcp/protocol/client.py +211 -0
  23. pyqt_runtime_mcp/protocol/errors.py +49 -0
  24. pyqt_runtime_mcp/protocol/messages.py +103 -0
  25. pyqt_runtime_mcp/protocol/transport.py +80 -0
  26. pyqt_runtime_mcp/server.py +57 -0
  27. pyqt_runtime_mcp/tools/__init__.py +12 -0
  28. pyqt_runtime_mcp/tools/application.py +99 -0
  29. pyqt_runtime_mcp/tools/common.py +122 -0
  30. pyqt_runtime_mcp/tools/diagnostics.py +95 -0
  31. pyqt_runtime_mcp/tools/interaction.py +160 -0
  32. pyqt_runtime_mcp/tools/screenshots.py +123 -0
  33. pyqt_runtime_mcp/tools/styles.py +42 -0
  34. pyqt_runtime_mcp/tools/widgets.py +87 -0
  35. pyqt_runtime_mcp-0.1.0.dist-info/METADATA +297 -0
  36. pyqt_runtime_mcp-0.1.0.dist-info/RECORD +40 -0
  37. pyqt_runtime_mcp-0.1.0.dist-info/WHEEL +5 -0
  38. pyqt_runtime_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  39. pyqt_runtime_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
  40. 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,6 @@
1
+ """Allow `python -m pyqt_runtime_mcp` to start the MCP stdio server."""
2
+
3
+ from pyqt_runtime_mcp.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,5 @@
1
+ """In-app PyQt5 runtime bridge."""
2
+
3
+ from pyqt_runtime_mcp.bridge.bridge import PyQtMCPBridge, install_pyqt_mcp
4
+
5
+ __all__ = ["PyQtMCPBridge", "install_pyqt_mcp"]
@@ -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