Files
cowork-local/tools/extract_controls.py
T
NamPDTandClaude Opus 5 291a611737 chore: sync local working copy as of 2026-08-15
The Gitea repo was initialised from an earlier snapshot, so main and the
machine this runs on had drifted apart in 153 files before any UI work
started. This commit brings the branch up to the local tree as it stood
on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows
shows up as its own reviewable diff instead of being mixed in with the
pre-existing divergence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:38:18 +09:00

138 lines
5.9 KiB
Python

"""Extract every interactive control from the UI source, mechanically.
Reading the files by hand and listing what I notice is exactly how functionality
gets dropped from a redesign. This walks the AST instead, so the inventory is
exhaustive by construction: if a widget is constructed in the file, it appears.
For each control it reports the variable it is bound to, its widget type, the
label expression (usually a ``tr("...")`` key), the signal handlers wired to it,
and the source line — enough to check "did the new design keep this?".
Run: python tools/extract_controls.py [ui/file.py ...]
"""
from __future__ import annotations
import ast
import io
import json
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
UI = REPO / "ui"
# Widget types that represent something the user can click, type in or toggle.
WIDGETS = {
"QPushButton": "nút", "QToolButton": "nút icon", "QComboBox": "droplist",
"QCheckBox": "ô tick", "QRadioButton": "radio", "QLineEdit": "ô nhập",
"QPlainTextEdit": "ô nhập nhiều dòng", "QTextEdit": "ô nhập nhiều dòng",
"QSpinBox": "ô số", "QDoubleSpinBox": "ô số", "QDateTimeEdit": "ô ngày giờ",
"QDateEdit": "ô ngày", "QTimeEdit": "ô giờ", "QSlider": "thanh trượt",
"QListWidget": "danh sách", "QTreeWidget": "cây", "QTableWidget": "bảng",
"QTabWidget": "dải tab", "QTabBar": "dải tab", "QDialogButtonBox": "nút hộp thoại",
}
# Signals worth recording — these are the "it does something" wires.
SIGNALS = {
"clicked", "toggled", "currentIndexChanged", "currentTextChanged",
"textChanged", "returnPressed", "valueChanged", "itemClicked",
"itemDoubleClicked", "currentItemChanged", "currentChanged",
"customContextMenuRequested", "tabCloseRequested", "linkActivated",
"stateChanged", "activated", "triggered", "editingFinished",
}
def _txt(node) -> str:
"""Best-effort source text for a label expression."""
try:
return ast.unparse(node)
except Exception: # noqa: BLE001
return "?"
class Visitor(ast.NodeVisitor):
def __init__(self, path: Path):
self.path = path
self.controls: dict[str, dict] = {} # var name -> record
self.menu_actions: list[dict] = []
# ---- self.btn = QPushButton(...) / btn = QComboBox() -------------------
def visit_Assign(self, node: ast.Assign) -> None:
if isinstance(node.value, ast.Call):
fn = node.value.func
name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", "")
if name in WIDGETS:
for tgt in node.targets:
var = _txt(tgt)
args = [_txt(a) for a in node.value.args]
self.controls.setdefault(var, {
"var": var, "type": name, "kind": WIDGETS[name],
"label": args[0] if args else "",
"line": node.lineno, "signals": [], "object_name": "",
})
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
fn = node.func
# ---- x.clicked.connect(handler) ----------------------------------
if isinstance(fn, ast.Attribute) and fn.attr == "connect":
sig = fn.value
if isinstance(sig, ast.Attribute) and sig.attr in SIGNALS:
var = _txt(sig.value)
rec = self.controls.get(var)
if rec is not None and node.args:
rec["signals"].append(f"{sig.attr} → {_txt(node.args[0])}")
# ---- x.setText(tr("...")) / setObjectName / setToolTip -----------
if isinstance(fn, ast.Attribute) and node.args:
var = _txt(fn.value)
rec = self.controls.get(var)
if rec is not None:
if fn.attr in ("setText", "setPlaceholderText") and not rec["label"]:
rec["label"] = _txt(node.args[0])
elif fn.attr == "setObjectName":
rec["object_name"] = _txt(node.args[0]).strip("'\"")
elif fn.attr == "setToolTip" and not rec["label"]:
rec["label"] = _txt(node.args[0])
# ---- menu.addAction("Xoá") — context menus are real features -----
if isinstance(fn, ast.Attribute) and fn.attr == "addAction" and node.args:
self.menu_actions.append({
"menu": _txt(fn.value), "label": _txt(node.args[0]),
"line": node.lineno,
})
self.generic_visit(node)
def scan(path: Path) -> dict:
tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path))
v = Visitor(path)
v.visit(tree)
# Drop pure containers with no wiring and no label — they are layout, not
# controls the user acts on directly.
controls = [c for c in v.controls.values()
if c["signals"] or c["label"] or c["object_name"]]
controls.sort(key=lambda c: c["line"])
return {"file": str(path.relative_to(REPO)),
"controls": controls, "menu_actions": v.menu_actions}
def main(argv: list[str]) -> int:
targets = [Path(a) for a in argv] or sorted(UI.glob("*.py"))
out = []
for t in targets:
if t.name == "__init__.py":
continue
p = t if t.is_absolute() else (REPO / t if (REPO / t).exists() else t)
try:
out.append(scan(p))
except SyntaxError as exc: # noqa: PERF203
print(f" SKIP {p.name}: {exc}", file=sys.stderr)
dest = REPO / "docs" / "screens" / "controls.json"
dest.write_text(json.dumps(out, indent=1, ensure_ascii=False), encoding="utf-8")
n_ctl = sum(len(f["controls"]) for f in out)
n_act = sum(len(f["menu_actions"]) for f in out)
print(f"{len(out)} file · {n_ctl} control · {n_act} mục menu chuột phải → {dest}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))