#!/usr/bin/env python3 """Draft a widget inventory of the Studio window, per view, as JSON. **The manifest is authored and reviewed, not generated.** ``docs/ui-elements.json`` is a spec: it states which elements the window is *supposed* to have, so that a test can fail when the code and the spec disagree. A generated file cannot do that — regenerating it would silently accept whatever the code happens to contain, which is exactly the drift the manifest exists to catch. This tool is therefore a **drafting aid**: run it to see the real tree, then curate the output by hand into the manifest (choose stable ids, drop style plumbing, write the one-line purpose, and record what is deliberately not modelled). Never copy its output over the manifest. Two further consequences of the same rule, so the tool is not misread as a verifier: * it reports what *is* in the tree, including widgets the manifest deliberately leaves out (styles such as ``contentPanel``, decorative labels, the settings dialog's copy); * the ``visible_named_widgets`` list it prints is the raw vocabulary to account for — every name on screen in that view. A name listed there is not a defect: the manifest accounts for it either as an element identity or as a declared stylesheet token, and what is left over is the ``views..unlisted_named_widgets`` drift the test compares. Usage:: python tools/ui_elements_dump.py # every view, box sensor python tools/ui_elements_dump.py --sensor finger # the 3D pane exists here python tools/ui_elements_dump.py --view live --out draft.json The layout of one view's output matches the manifest: a list of element records (``id``/``objectName``/``class``/``path``/``parent``/``visible``/``purpose``) plus the ``visible_named_widgets`` list for that view. """ from __future__ import annotations import argparse import json import os import sys from pathlib import Path APP_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = APP_ROOT.parents[1] for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): if _path.is_dir() and str(_path) not in sys.path: sys.path.insert(0, str(_path)) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6") if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" if _fonts.is_dir(): os.environ["QT_QPA_FONTDIR"] = str(_fonts) #: How deep the attribute walk follows containers. ``mesh3d_panel.view.panel.preset_combo`` #: is depth 4, so four levels plus one is enough; a deeper tree is a signal to add a path #: by hand rather than to raise this silently. MAX_DEPTH = 5 def _is_object(value) -> bool: from PySide6.QtCore import QObject return isinstance(value, QObject) def _is_widget(value) -> bool: from PySide6.QtWidgets import QWidget return isinstance(value, QWidget) def attribute_paths(root) -> dict[int, str]: """Map ``id(obj)`` to a dotted attribute path from ``root``. Only the *first* path found for an object is kept, shortest first, because a widget reachable two ways (``window.status_chip`` and ``window.toolbar.status_chip``) needs one canonical name and the shorter one is the better spec. Qt's own getters are seeded by hand: ``menuBar()`` and ``statusBar()`` are not instance attributes. """ from PySide6.QtWidgets import QMenuBar, QStatusBar, QWidget paths: dict[int, str] = {id(root): ""} def record(value, path: str) -> bool: known = paths.get(id(value)) if known is not None and len(known) <= len(path): return False paths[id(value)] = path return True def walk(node, prefix: str, depth: int) -> None: if depth > MAX_DEPTH: return try: attributes = list(vars(node).items()) except TypeError: return for name, value in attributes: if name.startswith("__"): continue child_path = f"{prefix}.{name}" if prefix else name if _is_object(value): if getattr(value, "parent", None) is None and not _is_widget(value): # A controller, a timer or a signal holder: not part of the tree. continue if record(value, child_path): walk(value, child_path, depth + 1) elif isinstance(value, (list, tuple)): for index, item in enumerate(value): if not _is_object(item): continue item_path = f"{child_path}[{index}]" if record(item, item_path): walk(item, item_path, depth + 1) record(root, "") walk(root, "", 0) # The two Qt getters that are not attributes: seeded so their children get real paths. for getter, name in ((root.menuBar, "menuBar()"), (root.statusBar, "statusBar()")): try: child = getter() except Exception: # noqa: BLE001 - a plain QWidget has neither of them child = None if child is None: continue if record(child, name): walk(child, name, 1) # Any other widget Qt owns that the walk did not reach, so the dump is complete. The # fallback is recorded only where no attribute path was found: an empty path is the # shortest of all and would otherwise overwrite every real one. for child in root.findChildren(QWidget): paths.setdefault(id(child), "") for child in root.findChildren(QMenuBar) + root.findChildren(QStatusBar): paths.setdefault(id(child), "") return paths def _parent_path(widget, paths: dict[int, str]) -> str: node = widget.parent() if hasattr(widget, "parent") else None while node is not None: path = paths.get(id(node)) if path is not None: return path node = node.parent() if hasattr(node, "parent") else None return "" def dump_view(window, view: str) -> dict: """One view's element records, in the manifest's shape.""" from orisys_studio.ui import elements as elements_mod from PySide6.QtWidgets import QApplication window.layout_ctl.apply_preset(view) for _ in range(3): QApplication.processEvents() paths = attribute_paths(window) records = [] for widget in elements_mod.descendants(window): path = paths.get(id(widget), "") records.append( { "id": "", "path": path, "objectName": widget.objectName() or None, "class": type(widget).__name__, "parent": _parent_path(widget, paths), "visible": bool(widget.isVisible()), "purpose": "", } ) records.sort(key=lambda item: (item["path"], item["class"])) return { "view": view, "preset": window.layout_ctl.view_preset, "elements": records, "visible_named_widgets": sorted(elements_mod.visible_named_widgets(window)), } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "--view", action="append", default=None, help="view to dump (repeatable; default: all three)", ) parser.add_argument("--sensor", default="box", help="sensor type for the window") parser.add_argument("--theme", default="dark", help="theme for the window") parser.add_argument("--out", default=None, help="write here instead of stdout") args = parser.parse_args(argv) from orisys_studio.app_window import StudioMainWindow from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication(sys.argv) window = StudioMainWindow( prefs={ "language": "zh_CN", "theme": args.theme, "sensor_type": args.sensor, "source_mode": "camera", } ) window.resize(1440, 880) window.show() app.processEvents() try: views = args.view or ["live", "replay", "calibration"] payload = { "window": { "id": "main_window", "class": type(window).__name__, "objectName": window.objectName() or None, }, "sensor": args.sensor, "theme": args.theme, "views": {view: dump_view(window, view) for view in views}, } finally: window.close() text = json.dumps(payload, indent=2, ensure_ascii=False) if args.out: Path(args.out).write_text(text + "\n", encoding="utf-8") print(f"wrote {args.out}") else: print(text) return 0 if __name__ == "__main__": raise SystemExit(main())