Files
orisys-app/apps/studio/tools/ui_elements_dump.py
T
ykcheungab eb70ac7638 add the Studio element manifest and its window-matching test
ui-layout.md named the arrangement's owners but nothing knew which elements exist, so a
view switch could silently take a running session's tracker reset away: docs/ui-elements.json
now states all 195 by region, pane and view, ui/elements.py loads it, and
tools/ui_elements_dump.py drafts it.
Verified: apps/studio 393 items (13 new) and ui_check 600/0, autocal 358, shared 390 with its
2 known wall-clock failures, lint and doc gates clean; a removed element, a renamed
objectName and a new named widget each fail the suite.
2026-10-01 15:18:16 +08:00

235 lines
8.7 KiB
Python

#!/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.<view>.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())