Files
cowork-local/tools/extract_controls.py
1419587401
CI / test (push) Canceled after 0s
Feature/fsg gamma team ui fix (#3)
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [x] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Co-authored-by: NamPDT <minhanhpkpro@gmail.com>
Reviewed-on: #3
2026-08-20 12:12:56 +00: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:]))