chore(gdb): add widget_specs JSON and spec-driven dashboard rendering

- Add widget_specs.json with auto-generated field types and hand-written
  summary templates, primary fields, and enum name mappings
- Generator merges _auto section with hand-written overrides on each run
- data_collector outputs widget_specs to dashboard JSON top-level
- Update uinspy frontend with spec-driven detail panel and tree summary
This commit is contained in:
Benign X
2026-05-24 19:18:25 +08:00
committed by VIFEX
parent 6415fd34b7
commit 6416db4dcf
4 changed files with 961 additions and 39 deletions
@@ -58,6 +58,8 @@ def collect_all() -> dict:
# Registry-driven simple collectors
for dict_key, accessor, label in SIMPLE_REGISTRY:
data[dict_key] = _collect_simple(lvgl, dict_key, accessor, label)
# Widget specs (global lookup table for uinspy rendering)
data["widget_specs"] = _collect_widget_specs(data.get("object_trees", []))
return data
@@ -220,3 +222,56 @@ def _collect_subjects_from_obj(obj, seen, result):
except CorruptedError:
# Children pointer unreadable: stop traversal for this subtree
pass
def _collect_widget_specs(object_trees: list) -> dict:
"""Build widget_specs dict for all class_names found in object trees."""
import json as _json
from pathlib import Path
specs_path = Path(__file__).parent.parent.parent.parent / "scripts" / "generators" / "widget_specs.json"
if not specs_path.exists():
return {}
all_specs = _json.loads(specs_path.read_text())
# Collect all class_names from object trees
seen = set()
def _walk(node):
cn = node.get("class_name", "")
if cn:
seen.add(cn)
for child in node.get("children", []):
_walk(child)
for tree in object_trees:
for screen in tree.get("screens", []):
_walk(screen)
# Build output: merge _auto + enums into flat fields spec
result = {}
for cn in seen:
raw = all_specs.get(cn) or all_specs.get("lv_" + cn)
if not raw:
continue
auto_fields = raw.get("_auto", {}).get("fields", {})
enums = raw.get("enums", {})
fields = {}
for fname, ftype in auto_fields.items():
entry = {"type": ftype}
if fname in enums:
entry["type"] = "enum"
entry["names"] = enums[fname]
fields[fname] = entry
spec = {}
if raw.get("summary_tpl") is not None:
spec["summary_tpl"] = raw["summary_tpl"]
if raw.get("primary"):
spec["primary"] = raw["primary"]
if fields:
spec["fields"] = fields
if spec:
result[cn] = spec
return result
@@ -1137,6 +1137,10 @@ ui-topbar .topbar-search::placeholder {
.obj-node > summary:hover {
background-color: var(--color-hover-summary);
}
.obj-summary-hint {
margin-left: calc(var(--spacing) * 1);
color: var(--color-overlay0);
}
.obj-node.obj-selected > summary {
background-color: var(--color-nav-active-bg);
outline: 1px solid var(--blue);
@@ -1815,7 +1819,27 @@ function selectObj(addr) {
document.getElementById("obj-" + prev)?.classList.remove("obj-selected");
selectedAddr.val = addr, document.getElementById("obj-" + addr)?.classList.add("obj-selected");
}
var dashData = signal(null);
var dashData = signal(null), widgetSpecs = {};
function setWidgetSpecs(specs) {
widgetSpecs = specs;
}
function getWidgetSpec(className) {
return widgetSpecs[className] || widgetSpecs["lv_" + className];
}
function widgetSummary(className, wd) {
if (!wd)
return "";
let spec = getWidgetSpec(className);
if (!spec?.summary_tpl)
return "";
return spec.summary_tpl.replace(/\{(\w+)\}/g, (_, k) => {
let v = wd[k];
if (v == null)
return "";
let s = String(v);
return s.length > 24 ? s.slice(0, 24) + "…" : s;
});
}
function countObjects(trees) {
let n = 0;
function walk(obj) {
@@ -2100,9 +2124,17 @@ function buildCard(item, config) {
}
// src/builders/obj-tree.ui.ts
function formatWdVal(v) {
function formatField(v, spec) {
if (v == null)
return "-";
if (spec) {
if (spec.type === "enum" && spec.names && typeof v === "number")
return spec.names[v] ?? String(v);
if (spec.type === "bool")
return v ? "true" : "false";
if (spec.type === "string" && typeof v === "string")
return v.length > 60 ? '"' + v.slice(0, 60) + '…"' : '"' + v + '"';
}
if (typeof v === "object")
return JSON.stringify(v);
return String(v);
@@ -2112,7 +2144,15 @@ function renderObjTree(obj, depth = 0) {
if (det.className = "obj-node", obj.addr)
det.id = "obj-" + obj.addr;
let sum = document.createElement("summary");
if (sum.style.setProperty("--depth-color", DEPTH_COLORS[depth % DEPTH_COLORS.length]), sum.textContent = (obj.name ? obj.name + " " : "") + (obj.class_name || "obj"), obj.flags_list?.includes("HIDDEN"))
sum.style.setProperty("--depth-color", DEPTH_COLORS[depth % DEPTH_COLORS.length]);
let nameText = (obj.name ? obj.name + " " : "") + (obj.class_name || "obj");
sum.textContent = nameText;
let hint = widgetSummary(obj.class_name, obj.widget_data);
if (hint) {
let span = document.createElement("span");
span.className = "obj-summary-hint", span.textContent = hint, sum.appendChild(span);
}
if (obj.flags_list?.includes("HIDDEN"))
sum.textContent += " \uD83D\uDC41\uD83D\uDDE8";
if (det.appendChild(sum), obj.addr)
objDataMap[obj.addr] = obj, registerHL(obj.addr, det), sum.addEventListener("mouseenter", () => highlightObj(obj.addr)), sum.addEventListener("mouseleave", () => clearHighlight()), sum.addEventListener("click", (e) => {
@@ -2187,23 +2227,25 @@ function renderObjDetail(addr, panel) {
refSec.appendChild(kvPair("user_data", obj.user_data));
if (obj.name)
refSec.appendChild(kvPair("name", obj.name));
if (refSec.appendChild(kvPair("children", String(obj.child_count || 0))), refSec.appendChild(kvPair("styles", String(obj.style_count || 0))), panel.appendChild(refSec), obj.scroll || obj.ext_click_pad || obj.ext_draw_size || obj.scrollbar_mode || obj.layer_type || obj.w_layout || obj.h_layout) {
refSec.appendChild(kvPair("children", String(obj.child_count || 0))), refSec.appendChild(kvPair("styles", String(obj.style_count || 0))), panel.appendChild(refSec);
let _n = (v) => v != null;
if (obj.scroll || _n(obj.ext_click_pad) || _n(obj.ext_draw_size) || _n(obj.scrollbar_mode) || _n(obj.layer_type) || obj.w_layout || obj.h_layout) {
let layoutSec = el("div", "detail-section");
if (layoutSec.appendChild(el("div", "detail-section-title", "Layout & Scroll")), obj.scroll)
layoutSec.appendChild(kvPair("scroll", `${obj.scroll.x}, ${obj.scroll.y}`));
if (obj.ext_click_pad)
if (_n(obj.ext_click_pad))
layoutSec.appendChild(kvPair("ext_click_pad", String(obj.ext_click_pad)));
if (obj.ext_draw_size)
if (_n(obj.ext_draw_size))
layoutSec.appendChild(kvPair("ext_draw_size", String(obj.ext_draw_size)));
if (obj.scrollbar_mode != null)
if (_n(obj.scrollbar_mode))
layoutSec.appendChild(kvPair("scrollbar_mode", String(obj.scrollbar_mode)));
if (obj.scroll_dir != null)
if (_n(obj.scroll_dir))
layoutSec.appendChild(kvPair("scroll_dir", String(obj.scroll_dir)));
if (obj.scroll_snap_x)
if (_n(obj.scroll_snap_x))
layoutSec.appendChild(kvPair("scroll_snap_x", String(obj.scroll_snap_x)));
if (obj.scroll_snap_y)
if (_n(obj.scroll_snap_y))
layoutSec.appendChild(kvPair("scroll_snap_y", String(obj.scroll_snap_y)));
if (obj.layer_type)
if (_n(obj.layer_type))
layoutSec.appendChild(kvPair("layer_type", String(obj.layer_type)));
if (obj.w_layout)
layoutSec.appendChild(kvPair("w_layout", "true"));
@@ -2226,38 +2268,23 @@ function renderObjDetail(addr, panel) {
intSec.appendChild(wrap), panel.appendChild(intSec);
}
if (obj.widget_data && Object.keys(obj.widget_data).length) {
let wd = obj.widget_data, primary = {
lv_label: ["text", "long_mode", "recolor"],
lv_image: ["src", "w", "h", "rotation", "scale_x", "scale_y", "align"],
lv_bar: ["cur_value", "min_value", "max_value", "start_value", "mode"],
lv_slider: ["cur_value", "min_value", "max_value", "start_value", "mode", "dragging"],
lv_arc: ["value", "min_value", "max_value", "rotation", "type"],
lv_switch: ["anim_state", "orientation"],
lv_checkbox: ["txt"],
lv_dropdown: ["options", "option_cnt", "sel_opt_id", "dir"],
lv_textarea: ["placeholder_txt", "max_length", "pwd_show_time"],
lv_tabview: ["tab_cur", "tab_pos", "tab_bar_size"],
lv_roller: ["option_cnt", "sel_opt_id", "mode"],
lv_chart: ["point_cnt", "hdiv_cnt", "vdiv_cnt", "type"],
lv_scale: ["mode", "range_min", "range_max", "total_tick_count", "angle_range", "rotation"],
lv_spinner: ["duration", "angle"],
lv_keyboard: ["mode", "popovers"],
lv_led: ["color", "bright"],
lv_spinbox: ["value", "range_min", "range_max", "step", "digit_count", "dec_point_pos"],
lv_calendar: ["today", "showed_date"],
lv_table: ["col_cnt", "row_cnt"],
lv_buttonmatrix: ["btn_cnt", "row_cnt", "btn_id_sel", "one_check"]
}[obj.class_name] || [], allKeys = Object.keys(wd), priKeys = allKeys.filter((k) => primary.includes(k)), advKeys = allKeys.filter((k) => !primary.includes(k)), wdSec = el("div", "detail-section");
let wd = obj.widget_data, spec = getWidgetSpec(obj.class_name), fieldSpecs = spec?.fields || {}, primary = spec?.primary || [], allKeys = Object.keys(wd), priKeys = primary.filter((k) => (k in wd)), advKeys = allKeys.filter((k) => !priKeys.includes(k)), wdSec = el("div", "detail-section");
wdSec.appendChild(el("div", "detail-section-title", "Widget · " + obj.class_name));
let renderField = (k) => {
let fs = fieldSpecs[k], raw = wd[k];
wdSec.appendChild(kvPair(k, formatField(raw, fs)));
};
for (let k of priKeys.length ? priKeys : allKeys.slice(0, 6))
wdSec.appendChild(kvPair(k, formatWdVal(wd[k])));
renderField(k);
let rest = priKeys.length ? advKeys : allKeys.slice(6);
if (rest.length) {
let toggle = el("details", "detail-adv-toggle");
toggle.appendChild(el("summary", "detail-adv-summary", `${rest.length} more fields`));
let inner = el("div", "");
for (let k of rest)
inner.appendChild(kvPair(k, formatWdVal(wd[k])));
for (let k of rest) {
let fs = fieldSpecs[k];
inner.appendChild(kvPair(k, formatField(wd[k], fs)));
}
toggle.appendChild(inner), wdSec.appendChild(toggle);
}
panel.appendChild(wdSec);
@@ -3223,7 +3250,7 @@ class UiDashboard extends BaseComponent {
let data = dashData.val;
if (!data)
return;
grid.innerHTML = "";
setWidgetSpecs(data.widget_specs || {}), grid.innerHTML = "";
let bento = el("div", "bento"), objCount = countObjects(data.object_trees || []);
STAT_DEFS.forEach((s) => {
let val = s.key === "_objects" ? objCount : data[s.key]?.length || 0;
@@ -3235,7 +3262,7 @@ class UiDashboard extends BaseComponent {
customElements.define("ui-dashboard", UiDashboard);
// src/app.ts
document.getElementById("about").innerHTML = `<span>uinspy v${"0.4.6"}</span><span>·</span><span>Built ${"2026-04-02 13:52 GMT+8"}</span><span>·</span><span>${"07131c2"}</span><span>·</span><span>${"Canvas2D"}</span><span>·</span><a href="https://github.com/W-Mai/uinspy" target="_blank" rel="noopener">GitHub</a><span>·</span><a href="https://lvgl.io" target="_blank" rel="noopener">LVGL</a><span>·</span><span>MIT</span>`;
document.getElementById("about").innerHTML = `<span>uinspy v${"0.4.6"}</span><span>·</span><span>Built ${"2026-04-21 19:26 GMT+8"}</span><span>·</span><span>${"1b5dc67"}</span><span>·</span><span>${"Canvas2D"}</span><span>·</span><a href="https://github.com/W-Mai/uinspy" target="_blank" rel="noopener">GitHub</a><span>·</span><a href="https://lvgl.io" target="_blank" rel="noopener">LVGL</a><span>·</span><span>MIT</span>`;
</script>
</body>
</html>
@@ -19,6 +19,7 @@ Usage (from the GDB script root):
import re
import sys
import json
from pathlib import Path
from dataclasses import dataclass, field as dc_field
@@ -28,6 +29,7 @@ LVGL_SRC = Path(__file__).parent.parent.parent.parent.parent / "src"
LVGL_INC = Path(__file__).parent.parent.parent.parent.parent / "include" / "lvgl"
WIDGETS_DIR = LVGL_SRC / "widgets"
OUTPUT_DIR = Path(__file__).parent.parent.parent / "lvglgdb" / "lvgl" / "widgets"
SPECS_YAML = Path(__file__).parent / "widget_specs.yaml"
SIMPLE_INT_TYPES = {
"int8_t", "int16_t", "int32_t", "int64_t",
@@ -268,6 +270,33 @@ def _field_expr(f: StructField) -> str | None:
return None
def _field_type_name(f: StructField) -> str | None:
"""Infer a spec type name for a field. Returns None to skip."""
if f.is_array:
return None
if f.is_obj_pointer:
return "pointer"
if f.is_string:
return "string"
if f.is_pointer:
return "pointer"
if f.c_type == "lv_color_t":
return "color"
if f.c_type == "lv_area_t":
return "area"
if f.c_type == "lv_point_t":
return "point"
if f.is_bitfield:
return "bool" if f.bitfield_width == 1 else f"enum:{f.bitfield_width}"
if f.c_type == "bool":
return "bool"
if f.c_type in SIMPLE_INT_TYPES or f.c_type.startswith(("uint", "int")):
return "int"
if f.c_type.startswith("lv_") and f.c_type.endswith("_t"):
return "int"
return None
def _topo_sort(widgets: dict[str, WidgetDef]) -> list[WidgetDef]:
result, visited = [], set()
def visit(w):
@@ -496,6 +525,61 @@ def gen_init(ordered: list[WidgetDef]) -> str:
return "\n".join(lines)
SPECS_JSON = Path(__file__).parent / "widget_specs.json"
def _build_auto_spec(wdef: WidgetDef) -> dict:
"""Build the _auto section for a widget from parsed fields."""
fields = {}
for f in wdef.fields:
t = _field_type_name(f)
if t:
fields[f.name] = t
return {"fields": fields}
def update_specs_json(widgets: dict[str, WidgetDef]) -> dict:
"""Merge auto-generated field info with hand-written specs.
- _auto section is always regenerated
- Hand-written keys (summary_tpl, primary, enums) are preserved
- New widgets get an empty template
- Removed widgets get _removed: true
"""
existing = {}
if SPECS_JSON.exists():
existing = json.loads(SPECS_JSON.read_text())
result = {}
# Preserve _comment
if "_comment" in existing:
result["_comment"] = existing["_comment"]
else:
result["_comment"] = "Auto-generated + hand-written widget specs. _auto is regenerated; other keys are preserved."
seen = set()
for wdef in sorted(widgets.values(), key=lambda w: w.c_class_name):
key = wdef.c_class_name # e.g. "lv_label"
seen.add(key)
old = existing.get(key, {})
entry = {"_auto": _build_auto_spec(wdef)}
# Preserve hand-written keys
for k in ("summary_tpl", "primary", "enums"):
if k in old:
entry[k] = old[k]
result[key] = entry
# Mark removed widgets
for key, val in existing.items():
if key.startswith("_") or key in seen:
continue
val["_removed"] = True
result[key] = val
SPECS_JSON.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
return result
def main():
widgets = parse_widgets()
ordered = _topo_sort(widgets)
@@ -504,6 +588,11 @@ def main():
for w in ordered:
print(f" {w.module_name}.py: {w.class_name}({w.parent_class_name}) — {len(w.fields)} fields")
# Update specs JSON (merge auto + hand-written)
specs = update_specs_json(widgets)
new_count = sum(1 for k, v in specs.items() if not k.startswith("_") and "summary_tpl" not in v)
print(f"\nUpdated {SPECS_JSON.name}: {len(specs) - 1} widgets ({new_count} need manual spec)")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# _helpers.py
@@ -517,7 +606,7 @@ def main():
# __init__.py
(OUTPUT_DIR / "__init__.py").write_text(gen_init(ordered))
print(f"\nGenerated {len(ordered) + 2} files in {OUTPUT_DIR}/")
print(f"Generated {len(ordered) + 2} files in {OUTPUT_DIR}/")
if __name__ == "__main__":
File diff suppressed because it is too large Load Diff