eb70ac7638
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.
235 lines
8.7 KiB
Python
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())
|