Files
STM32F4-Base/tools/menu_gen/gen_menu.py
T
wandering1 dafc18f5ed feat(UI): W6 第一波——设计令牌层、页面注册表生成器与 UI 门禁补强
启动 docs/任务规划/README.md 的 W6 UI 扩展簇(用户裁定,覆盖 2026-10-11
的"本轮不排期")。四个工作包,全部验证通过。

## U1 设计令牌层(零行为变化)
新建 Drivers/BSP/UI/ui_tokens.h(20 个宏),框架 + 7 个页面 + App/task/
ui_task.c 全部接入。屏幕几何**复用 ui_port.h 既有 UI_SCREEN_W/H**,不重复
定义;令牌头自身只 include ui_port.h,不把 LVGL 拖进全部消费者。
补收了报告没列但代码里真重复的量:UI_TEXT_BAND_H 15(7 次)、列表左右边界
与每页行数(各 4 个页面逐字复制)、推流协议几何与显示几何同源(此前硬编码
3200 且注释写着"须与 UI_FB_SIZE 一致"——典型漂移隐患)、UI_TASK_TICK_MS
(ui_port_step 与 osDelay 必须同值,原先靠注释维系)。
故意不收页面私有几何与"同形不同义"的值(device_info 的 pitch=15 与列表
UI_ROW_PITCH=14 合并即改像素)。

## U3 页面注册表与生成器(删手工分发)
ui_page_registry.{c,h} 由生成器产出,**手工 menu_content_page_for() 已删
除**(仓库内仅存 2 处历史说明注释)。密码页按代码实况**单独出口**
(ui_page_registry_password())——它非节点绑定,任何 #P 叶子进同一页。
顺带落地此前判定"U1 阶段做不了"的共享 flat 样式 ui_style.{c,h}。
生成器除产注册表外**自动把新 .c 写进 uvprojx 与 CMakeLists.txt**,且
**双向对账**:加页打印 registered、删页打印 pruned,不留死条目;并双向
校验(#page 找不到实例报错、实例未被任何 #page 引用也报错)。

## U4 门禁补强(35 个钩子)
新增 font-charset 钩子(scripts/check_font_charset.py):复用
tools/font_gen 的同一套提取与 cmap 解析规则(importlib 直载,零第二套
规则),A 段查源码在用 vs 字符集、B 段查字符集 vs 字体 cmap。
registration-check 补第 ④ 类:实测原规则对"进了两端构建但没接描述符表"
的页面**全绿**——desc 被链接器剥离、编译链接全过、菜单永远打不开的死代码。
仍照不出的两项已如实写进文档(枚举漏加归 @build、菜单树漏挂归 golden)。

## 字体子集瘦身(D 案,4 留 3 删)
ui_cn_chars.txt 122→119 字(恰删 上/电/重,零新增)。异/常/片/芯 进
KEEP_EXTRA 带理由白名单:它们曾在 9eb1bd63 被写进中文自检描述落盘
/log/selftest.log(定长 64B 记录跨固件持久),删字形会使历史故障记录
渲染空白且不可逆。保留字按**必需**处理,反向有牙——谁只跑 extract_cn
把它们静默洗掉,钩子直接 FAIL 并逐字打印理由。
--strict 口径为"未知冗余 = 0"而非"冗余 = 0"。

## 验证
@build 0 错 0 警;ER_IROM1 232,052 B(较 HEAD −228 B)、RW_IRAM1
75,232 B **零增量**、RW_CCM 87.9% 未变。golden:菜单 8 状态全像素一致 +
内容页 11 页逐字节一致,**参考图 sha256 差异 0 条**(未用 --update 刷
基线);池水位菜单基线 5368 B、开页-关页往返 5448 B 与 U1 时相同。
38 个在途修改文件逐个核对 BOM/CRLF/U+FFFD 全部与 HEAD 一致。
L0 33 钩子 + L0.5 16 runner / 1104 断言全绿;check_doc_links --all
142 篇、无法判定 0;mem-budget / mem-numbers 已回填。

## 单位成本实测(U5 排期依据)
临时页 zzprobe 绑到"遥信量"叶子 → 只跑生成器 → 带页 @build 0/0 →
注册表出现 {5u, &ui_page_zzprobe_desc} → 回滚后 6 个产出文件 zzprobe
残留计数均 0、重编回基线。
**结论:新增一页 = 3 处人工(新 .c + 新 .h + 菜单树那一行加
 #page=<名>)+ 1 条生成器命令,手工编辑注册文件 0 处。**
据此修正报告原口径"8 处降到 1 处"——那个 1 只在"1 处对既有文件的修改"
意义下成立,页面自身 2 个文件本就要写,真实处数是 3。

## 文档同步与两处自我修正
§0.1 UI 行 B → B+;§6.1「内容页」「设计令牌」两行按实况回填;§6.5 U1/U3
状态更新;§6 顶部加"建议栏不可照抄"总警示(本轮四次证伪:ui_port.h 已有
同名宏、LV_COLOR_BLACK/WHITE 不存在且极性反了、27 页实为目标页数现存 7
个、生成器草图漏密码页非节点绑定)。
两处改口:① §6.5 U4 从我上一版**误写的 3/3 改回 2/3**——"golden 脚本早已
存在"不等于"已进自动门禁",两个 golden 脚本至今未挂 pre-commit,该缺口
单列为待办;② 原计划"分三笔提交"**作废**,U1 与 U3 改同一批文件、文件内
交叠,硬切会产生不可编译的中间态,故整波一笔。
2026-10-20 21:14:14 +08:00

654 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成菜单节点表与页面注册表(输出到 Drivers/BSP/UI/)。
把"菜单长什么样"声明在缩进文本源 ui_menu_tree.txt(缩进即层级,每级 2 空格,
`#` 开头为注释;行尾可选注记,可叠加:
` #P` 该叶子需密码(密码页按所输密码授权:查看/修改)
` #page=<名>` 该叶子绑定内容页实例 ui_page_<名>.c(描述符 ui_page_<名>_desc)
文件级指令(写在注释行里):
`# @password=<名>` 需密码叶子统一进的密码页模块名(省略 = 无密码页)
密码值在 Drivers/BSP/UI/ui_pwd_def.h 编译期配置。
本脚本读源、推导每个节点的 parent 下标,产出:
① ui_menu_tab.c / .h 菜单节点扁平父子索引表(供 ui_menu.c 消费)
② ui_page_registry.c/.h 叶子节点 → 页面描述符注册表(取代手写 case 分发)
③ 双端注册:把 ui_page_registry.c 与各绑定页 .c 自动写进
MDK-ARM/STM32F407-Demo.uvprojx 与 simulator/CMakeLists.txt 的显式列表
(只做定点插入、不重排文件;已存在则不动 = 幂等)
并校验:声明了 #page 但实例文件/符号缺失 → 报错;定义了 desc 却无叶子绑定 → 报错。
改菜单/加页面只需编辑 ui_menu_tree.txt + 写页面实例文件,然后重跑本脚本。
用法(仓库根目录或任意目录均可,脚本自动定位仓库根):
python tools/menu_gen/gen_menu.py
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SRC = os.path.join(ROOT, 'ui_menu_tree.txt')
OUT_H = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_menu_tab.h')
OUT_C = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_menu_tab.c')
OUT_REG_H = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_page_registry.h')
OUT_REG_C = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_page_registry.c')
UI_DIR = os.path.join(ROOT, 'Drivers', 'BSP', 'UI')
UVPROJX = os.path.join(ROOT, 'MDK-ARM', 'STM32F407-Demo.uvprojx')
CMAKELISTS = os.path.join(ROOT, 'simulator', 'CMakeLists.txt')
UI_GROUP = 'Drivers/BSP/UI' # uvprojx 里的组名
ROOT_ID = 0xFF # 内部根标记(= MENU_ROOT=0xFF);与 8 位索引一致,映射到 C 的 MENU_ROOT 宏
MAX_DEPTH = 8
MAX_NODES = 255 # uint8_t 索引 + 哨兵 0xFF → 最多 255 个节点(下标 0..254)
STEM_RE = re.compile(r'^[a-z][a-z0-9_]*$')
# 行尾注记串:一个或多个 ` #KEY` / ` #KEY=VAL`
ANN_TAIL_RE = re.compile(r'((?:[ \t]+#[A-Za-z0-9]+(?:=[^\s#]+)?)+)$')
ANN_ONE_RE = re.compile(r'#([A-Za-z0-9]+)(?:=([^\s#]+))?')
# 文件级指令:`# @key=value`(整行注释,不与节点冲突)
DIRECTIVE_RE = re.compile(r'^[ \t]*#[ \t]*@([A-Za-z0-9_]+)[ \t]*=[ \t]*(\S+)')
def parse_annotations(stripped, raw):
"""剥出行尾注记,返回 (节点名, {'P': bool, 'page': str|None})。
节点名原样保留(含内部空格),只去掉尾部 ` #KEY[=VAL]` 串。
未知注记 / #P 带值 / #page 缺值 一律报错——注记写错必须当场失败,
不能静默降级成"没密码/没页面"(那类错误两端编译全过、只在菜单里打不开页)。
"""
m = ANN_TAIL_RE.search(stripped)
if not m:
return stripped, {'P': False, 'page': None}
name = stripped[:m.start()]
pwd, page = False, None
for tok in ANN_ONE_RE.findall(m.group(1)):
key, val = tok[0].upper(), tok[1] or None
if key == 'P':
if val is not None:
sys.exit('ERROR: #P 不接受取值: %r' % raw)
pwd = True
elif key == 'PAGE':
if val is None:
sys.exit('ERROR: #page 需要取值(页面模块名): %r' % raw)
if page is not None:
sys.exit('ERROR: #page 重复: %r' % raw)
if not STEM_RE.match(val):
sys.exit('ERROR: #page 取值须为小写字母/数字/下划线且以字母开头: %r(出现 %r)'
% (raw, val))
page = val
else:
sys.exit('ERROR: 行尾注记仅支持 #P(需密码) 与 #page=<模块名>(绑定内容页): '
'%r(出现 #%s;旧 #V/#M 类别注记已废弃,密码值改在 ui_pwd_def.h 配置)'
% (raw, tok[0]))
return name, {'P': pwd, 'page': page}
def parse_tree(text):
"""把缩进文本解析为 (nodes, directives)。
nodes = [(parent, name, depth, pwd, page), ...];
parent 为父节点下标,根节点为 ROOT_ID;子项 = parent 等于本下标的所有行(按表序)。
缩进单元取"最小非零缩进"(每级一个单元),据此把缩进换算成层级。
directives = {小写指令名: 取值},来自 `# @key=value` 注释行。
"""
lines = []
directives = {}
for raw in text.splitlines():
line = raw.rstrip()
stripped = line.strip()
if not stripped:
continue
if stripped.startswith('#'):
dm = DIRECTIVE_RE.match(line)
if dm:
key = dm.group(1).lower()
if key in directives:
sys.exit('ERROR: 指令重复定义: @%s' % key)
directives[key] = dm.group(2)
continue
if '\t' in line:
sys.exit('ERROR: 请用空格缩进(勿用 Tab): ' + repr(raw))
indent = len(line) - len(line.lstrip(' '))
name, ann = parse_annotations(stripped, raw)
lines.append((indent, name, ann))
if not lines:
sys.exit('ERROR: ui_menu_tree.txt 无有效节点')
indents = sorted(set(i for i, _, _ in lines if i > 0))
unit = indents[0] if indents else 1
for i, name, _ in lines:
if i % unit != 0:
sys.exit('ERROR: 缩进不是 %d 的整数倍: %r (indent=%d)' % (unit, name, i))
nodes = []
stack = [] # [(depth, index), ...] 当前各层的父节点下标
for indent, name, ann in lines:
depth = indent // unit
if depth > MAX_DEPTH:
sys.exit('ERROR: 层级过深(>%d): %r' % (MAX_DEPTH, name))
while stack and stack[-1][0] >= depth:
stack.pop()
parent = stack[-1][1] if stack else ROOT_ID
idx = len(nodes)
nodes.append((parent, name, depth, ann['P'], ann['page']))
stack.append((depth, idx))
roots = [i for i, (_, _, d, _, _) in enumerate(nodes) if d == 0]
if len(roots) != 1:
sys.exit('ERROR: 必须有且仅有一个根节点(depth 0),当前 %d 个: %s'
% (len(roots), [nodes[i][1] for i in roots]))
return nodes, directives
def emit(nodes):
n = len(nodes)
menus = sum(1 for i in range(n) if any(nodes[j][0] == i for j in range(n)))
max_depth = max(d for _, _, d, _, _ in nodes)
h = [
'/*',
' * 模块名称:UI 菜单节点表头文件(自动生成)',
' * 模块功能:菜单节点结构、表规模护栏与表查询 API 声明',
' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)',
' * 作者:王建锋',
' * 创建日期:2026-09-07',
' * 修改记录:',
' * 2026-09-13 王建锋 工程约定规范化(文件头),内容由生成器自动更新',
' * 自动生成:本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 自动生成,请勿手改。',
' * 修改菜单:编辑 ui_menu_tree.txt 后重跑 `python tools/menu_gen/gen_menu.py`。',
' */',
'#ifndef UI_MENU_TAB_H',
'#define UI_MENU_TAB_H',
'',
'#include <stdint.h>',
'#include <stdbool.h>',
'',
'#define MENU_ROOT 0xFFu /* 根哨兵:仅根节点(主界面)的 parent 取此值;8 位,上限 255 项 */',
'',
'/* 菜单节点:子项 = parent 等于本下标的所有行(按表序);有子项=菜单,无=叶子 */',
'/* 字段序 parent/pwd 在前:32 位下两项 8 位合并 + 4 位指针 = 8 B/项 */',
'typedef struct {',
' uint8_t parent; /* 父节点下标(0..254);根为 MENU_ROOT(0xFF) */',
' uint8_t pwd; /* 1 = 需密码(密码页按所输密码授权);0 = 直接进(只读) */',
' const char *name; /* 显示文本(字面量,存 Flash) */',
'} ui_menu_node_t;',
'',
'#define UI_MENU_TAB_COUNT %d' % n,
'',
'/* 护栏:8 位索引上限 255 项(哨兵 0xFF 占用 255);超界则编译失败(C99 写法,兼容 ARMCC v5) */',
'typedef char menu_tab_count_check[(UI_MENU_TAB_COUNT <= 255) ? 1 : -1];',
'',
'extern const ui_menu_node_t g_menu_tab[];',
'',
'/*',
' * 表查询 API(导航逻辑层 ui_menu.c 与显示层 ui_menu_view.c 共用):',
' * 实现随表生成,改菜单(ui_menu_tree.txt)调用方零代码改动。',
' */',
'uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max); /* 取 node 直接子项(按表序),最多 max 个;返回实际个数 */',
'bool ui_menu_tab_has_children(uint8_t node); /* 有子项=菜单(带▶);无=叶子 */',
'const char *ui_menu_tab_name(uint8_t node); /* 显示名 */',
'bool ui_menu_tab_has_pwd(uint8_t node); /* 需密码(密码页按所输密码授权);false = 直接进(只读) */',
'bool ui_menu_tab_valid(void); /* 启动自检:非根 parent 下标均有效、pwd 为 0/1 */',
'',
'#endif',
'',
]
c = [
'/*',
' * 模块名称:UI 菜单节点表(自动生成)',
' * 模块功能:菜单节点扁平父子索引表 + 5 个表查询 API(导航逻辑层与显示层共用)',
' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)',
' * 作者:王建锋',
' * 创建日期:2026-09-07',
' * 修改记录:',
' * 2026-09-13 王建锋 工程约定规范化(文件头 / K&R 大括号),内容由生成器自动更新',
' * 自动生成:本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 自动生成,请勿手改。',
' * 修改菜单:编辑 ui_menu_tree.txt 后重跑 `python tools/menu_gen/gen_menu.py`。',
' */',
'#include "ui_menu_tab.h"',
'',
'const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {',
]
for i, (parent, name, depth, pwd, _page) in enumerate(nodes):
pstr = 'MENU_ROOT' if parent == ROOT_ID else '%d' % parent
cstr = '1u' if pwd else '0u'
c.append(' { %8s, %2s, "%s" }, /* %2d L%d */' % (pstr, cstr, name, i, depth))
c.append('};')
c.append('')
c += [
'/*',
' * 函数功能:取 node 的直接子项(按表序),最多 max 个',
' * 入口参数:node - 节点下标 uint8_t',
' * out - 子节点下标输出缓冲 uint8_t *',
' * max - 缓冲容量 uint8_t',
' * 返回值:实际子项个数 uint8_t',
' * 限定条件:node < UI_MENU_TAB_COUNT',
' * 函数说明:子项 = 表中所有 parent == 本下标的行(表序即显示序);建页核心查询',
' */',
'uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max) {',
' uint8_t n = 0u;',
' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT && n < max; i++) {',
' if (g_menu_tab[i].parent == node) {',
' out[n++] = i;',
' }',
' }',
' return n;',
'}',
'',
'/*',
' * 函数功能:判断 node 有无子项',
' * 入口参数:node - 节点下标 uint8_t',
' * 返回值:有子项返回 true(=菜单、行带▶);无子项返回 false(=叶子)',
' * 限定条件:node < UI_MENU_TAB_COUNT',
' * 函数说明:菜单/叶子由"是否有子项"自动推导,无单独类型字段',
' */',
'bool ui_menu_tab_has_children(uint8_t node) {',
' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT; i++) {',
' if (g_menu_tab[i].parent == node) {',
' return true;',
' }',
' }',
' return false;',
'}',
'',
'/*',
' * 函数功能:取节点显示名',
' * 入口参数:node - 节点下标 uint8_t',
' * 返回值:显示文本(Flash 字面量) const char *',
' * 限定条件:node < UI_MENU_TAB_COUNT',
' * 函数说明:行文本一律经此取静态字符串,不 strdup',
' */',
'const char *ui_menu_tab_name(uint8_t node) {',
' return g_menu_tab[node].name;',
'}',
'',
'/*',
' * 函数功能:判断节点是否需密码(叶子密码保护)',
' * 入口参数:node - 节点下标 uint8_t',
' * 返回值:true - 需密码页(按所输密码授权查看/修改权限);false - 直接进(只读)',
' * 限定条件:无',
' * 说明:表 pwd 列取自源 ui_menu_tree.txt 行尾 #P 注记;导航逻辑层据此决定是否先经密码页',
' */',
'bool ui_menu_tab_has_pwd(uint8_t node) {',
' return (g_menu_tab[node].pwd != 0u);',
'}',
'',
'/*',
' * 函数功能:菜单表启动自检',
' * 入口参数:无',
' * 返回值:true - 表有效;false - 存在越界 parent(表内笔误)',
' * 限定条件:无',
' * 函数说明:每个非根节点的 parent 必须为 MENU_ROOT 或有效下标;在 ui_menu_init 调用',
' */',
'bool ui_menu_tab_valid(void) {',
' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT; i++) {',
' if (i == 0u) {',
' continue; /* 根 */',
' }',
' uint8_t p = g_menu_tab[i].parent;',
' if (p != MENU_ROOT && p >= UI_MENU_TAB_COUNT) {',
' return false;',
' }',
' if (g_menu_tab[i].pwd > 1u) {',
' return false; /* pwd 只取 0/1(行尾注记笔误捕获) */',
' }',
' }',
' return true;',
'}',
'',
]
return '\n'.join(h), '\n'.join(c), menus, max_depth
def has_children(nodes, idx):
"""节点是否有子项(有子项 = 菜单,进子框而非内容页)。"""
return any(n[0] == idx for n in nodes)
def page_files_on_disk():
"""扫描 Drivers/BSP/UI/ui_page_*.c,返回 {模块名: 文件名}。
判定与 scripts/check_registration.py 规则④一致:只有**定义了 ui_page_<名>_desc**
的 ui_page_*.c 才算页面实例;生成物 ui_page_registry.c 不定义该符号,天然排除。
"""
out = {}
for fn in sorted(os.listdir(UI_DIR)):
if not (fn.startswith('ui_page_') and fn.endswith('.c')):
continue
stem = fn[:-2][len('ui_page_'):]
if stem == 'registry':
continue
try:
text = open(os.path.join(UI_DIR, fn), encoding='utf-8-sig',
errors='replace').read()
except OSError:
continue
if re.search(r'\bui_page_%s_desc\b\s*=' % re.escape(stem), text):
out[stem] = fn
return out
def validate_pages(nodes, directives):
"""校验 #page 绑定与磁盘实例双向对齐,返回 ([(node, 节点名, 模块名)], 密码页模块名|None)。
五类错误一律当场失败(不产出半成品表):
① #page 标在有子项的节点上(那种节点进子框,永不进内容页)
② 声明了 #page / @password 但实例文件或描述符符号不存在
③ 磁盘上有页面实例却无任何叶子绑定(两端编译链接全过、菜单打不开 = 死代码)
④ 有 #P 叶子却没有 @password 指令(运行时取到 NULL 描述符,开页静默失败)
⑤ 有 @password 指令却没有任何 #P 叶子(密码页永不出现的死配置)
"""
disk = page_files_on_disk()
password_stem = directives.get('password')
bound = []
for idx, (parent, name, depth, pwd, page) in enumerate(nodes):
if page is None:
continue
if has_children(nodes, idx):
sys.exit('ERROR: #page 只能标在叶子上: %r (node %d) 有子项,该节点进子框而非内容页'
% (name, idx))
if page not in disk:
sys.exit('ERROR: 节点 %r (node %d) 声明 #page=%s,但 Drivers/BSP/UI/ui_page_%s.c '
'不存在或未定义 ui_page_%s_desc' % (name, idx, page, page, page))
bound.append((idx, name, page))
if password_stem is not None and password_stem not in disk:
sys.exit('ERROR: 指令 @password=%s 指向的 Drivers/BSP/UI/ui_page_%s.c 不存在'
'或未定义 ui_page_%s_desc' % (password_stem, password_stem, password_stem))
# 密码一致性:#P 叶子与 @password 指令必须成对出现。
# 只标 #P 不声明指令 → 运行时 ui_page_registry_password() 返回 NULL,按 ENTER 开页
# 失败停在菜单屏(两端编译全过、只在真按钥匙才暴露);只声明指令没有 #P 叶子 →
# 密码页永不出现,属死配置。两种都在生成期就拦。
protected_nodes = [idx for idx, (_p, _n, _d, pwd, _pg) in enumerate(nodes) if pwd]
if protected_nodes and password_stem is None:
sys.exit('ERROR: %d 个叶子标了 #P(需密码)但菜单树缺 `# @password=<模块名>` 指令:'
'这些叶子按 ENTER 会取到 NULL 描述符、开页失败停在菜单屏(node %s 等)'
% (len(protected_nodes), ', '.join(str(i) for i in protected_nodes[:5])))
if password_stem is not None and not protected_nodes:
sys.exit('ERROR: 指令 @password=%s 已声明,但没有任何叶子标 #P —— 密码页永不出现,'
'属死配置(要么给相应叶子加 #P,要么删掉该指令)' % password_stem)
used = {p for _, _, p in bound}
if password_stem:
used.add(password_stem)
orphans = sorted(s for s in disk if s not in used)
if orphans:
sys.exit('ERROR: 以下页面实例未被任何叶子 #page 引用(写了却从不生效的死代码): %s'
% ', '.join('ui_page_%s.c' % s for s in orphans))
return bound, password_stem
def emit_registry(bound, password_stem):
"""产出 ui_page_registry.h / .c 文本。"""
stems = sorted({p for _, _, p in bound} | ({password_stem} if password_stem else set()))
h = [
'/*',
' * 模块名称:UI 页面注册表头文件(自动生成)',
' * 模块功能:叶子节点 → 内容页描述符的映射表与查询 API;密码页非节点绑定,单独出口',
' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)',
' * 作者:王建锋',
' * 创建日期:2026-10-15',
' * 自动生成:本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 的 #page= 注记生成,请勿手改。',
' * 新增一页:菜单树给叶子加 " #page=<模块名>" + 写 ui_page_<模块名>.c,重跑生成器',
' * (双端注册 uvprojx/CMakeLists 亦由生成器自动写入)',
' */',
'#ifndef UI_PAGE_REGISTRY_H',
'#define UI_PAGE_REGISTRY_H',
'',
'#include <stdint.h>',
'',
'#include "ui_content_page.h"',
'',
'#ifdef __cplusplus',
'extern "C" {',
'#endif',
'',
'/* 注册表条目:一个绑定了内容页的菜单叶子(表序 = 节点下标升序) */',
'typedef struct {',
' uint8_t node; /* 菜单叶子节点下标(g_menu_tab 下标) */',
' const content_page_def_t *desc; /* 页面描述符(Flash const) */',
'} ui_page_entry_t;',
'',
'#define UI_PAGE_MAP_COUNT %du /* 绑定了内容页的叶子数 */' % len(bound),
'',
'extern const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT];',
'',
'/*',
' * 函数功能:查叶子节点绑定的内容页描述符',
' * 入口参数:node - 菜单叶子节点下标 uint8_t',
' * 返回值:页面描述符;该叶子未绑定内容页返回 NULL const content_page_def_t *',
' * 限定条件:node < UI_MENU_TAB_COUNT',
' * 函数说明:取代旧 ui_menu.c 手写 case 分发——映射的唯一真源是 ui_menu_tree.txt,',
' * 表序变化不会造成"下标硬编码错位"(线性扫描:绑定页是个位数量级)',
' */',
'const content_page_def_t *ui_page_registry_for(uint8_t node);',
'',
'/*',
' * 函数功能:取密码页描述符(需密码叶子统一进这一页)',
' * 入口参数:无',
' * 返回值:密码页描述符;菜单树未声明 @password 指令时返回 NULL const content_page_def_t *',
' * 限定条件:无',
' * 函数说明:密码页**不是**节点绑定的(任何 #P 叶子都进同一页),故不进 node→desc 映射,',
' * 由本接口单独出口;取值来自 ui_menu_tree.txt 的 `# @password=<模块名>` 指令',
' */',
'const content_page_def_t *ui_page_registry_password(void);',
'',
'#ifdef __cplusplus',
'}',
'#endif',
'',
'#endif /* UI_PAGE_REGISTRY_H */',
'',
]
c = [
'/*',
' * 模块名称:UI 页面注册表(自动生成)',
' * 模块功能:叶子节点 → 内容页描述符映射表 + 2 个查询 API(导航逻辑层消费)',
' * 适用平台:STM32F407ZGTx / PC 模拟器(共用源码)',
' * 作者:王建锋',
' * 创建日期:2026-10-15',
' * 自动生成:本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 的 #page= 注记生成,请勿手改。',
' * 新增一页:菜单树给叶子加 " #page=<模块名>" + 写 ui_page_<模块名>.c,重跑生成器',
' */',
'#include "ui_page_registry.h"',
'',
]
for s in stems:
c.append('#include "ui_page_%s.h"' % s)
c.append('')
c.append('const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT] = {')
for idx, name, page in bound:
c.append(' { %3du, &ui_page_%s_desc }, /* node %d %s */' % (idx, page, idx, name))
c.append('};')
c.append('')
c += [
'/*',
' * 函数功能:查叶子节点绑定的内容页描述符',
' * 入口参数:node - 菜单叶子节点下标 uint8_t',
' * 返回值:页面描述符;未绑定返回 NULL const content_page_def_t *',
' * 限定条件:无(越界 node 不会命中任何条目,直接 NULL)',
' * 函数说明:线性扫描绑定表(条目 = 有内容页的叶子,个位数量级)',
' */',
'const content_page_def_t *ui_page_registry_for(uint8_t node) {',
' for (uint8_t i = 0u; i < UI_PAGE_MAP_COUNT; i++) {',
' if (g_ui_page_map[i].node == node) {',
' return g_ui_page_map[i].desc;',
' }',
' }',
' return NULL;',
'}',
'',
'/*',
' * 函数功能:取密码页描述符(需密码叶子统一进这一页,非节点绑定)',
' * 入口参数:无',
' * 返回值:密码页描述符%s const content_page_def_t *'
% (';未声明 @password 时返回 NULL' if not password_stem else ''),
' * 限定条件:无',
' * 函数说明:取值来自 ui_menu_tree.txt 的 @password 指令(生成期解析,运行期零开销)',
' */',
'const content_page_def_t *ui_page_registry_password(void) {',
]
if password_stem:
c.append(' return &ui_page_%s_desc;' % password_stem)
else:
c.append(' return NULL;')
c.append('}')
c.append('')
return '\n'.join(h), '\n'.join(c)
def _read_text(path):
"""按原字节读文本(newline='' 防 Windows 把 LF 写成 CRLF)。"""
with open(path, 'r', encoding='utf-8', newline='') as fp:
return fp.read()
def _write_text(path, text):
with open(path, 'w', encoding='utf-8', newline='') as fp:
fp.write(text)
def prune_registration():
"""删掉两份注册表里指向**已不存在的 ui_page_*.c** 的死条目,返回被删项。
与 sync_registration 对称:删页/改名后残留的条目会让 Keil 直接报
"cannot open source input file"(模拟器同理报源文件缺失),属必须清掉的死引用;
只匹配 ui_page_*.c 这一类(生成器自己负责的范围),不碰其它组/其它模块的条目。
"""
pruned = []
uv = _read_text(UVPROJX)
blk_re = re.compile(r'[ \t]*<File>[ \t]*\r?\n'
r'[ \t]*<FileName>(ui_page_[a-z0-9_]*\.c)</FileName>'
r'.*?</File>[ \t]*\r?\n', re.S)
def _drop(m):
fn = m.group(1)
if os.path.isfile(os.path.join(UI_DIR, fn)):
return m.group(0)
pruned.append(('uvprojx', fn))
return ''
new_uv = blk_re.sub(_drop, uv)
if new_uv != uv:
_write_text(UVPROJX, new_uv)
cm = _read_text(CMAKELISTS)
line_re = re.compile(r'^[ \t]*\$\{UI_DIR\}/(ui_page_[a-z0-9_]*\.c)[ \t]*\r?\n', re.M)
def _drop_cm(m):
fn = m.group(1)
if os.path.isfile(os.path.join(UI_DIR, fn)):
return m.group(0)
pruned.append(('CMakeLists', fn))
return ''
new_cm = line_re.sub(_drop_cm, cm)
if new_cm != cm:
_write_text(CMAKELISTS, new_cm)
return pruned
def sync_registration(filenames):
"""把 UI 目录下的 .c 文件名补进 uvprojx 与 CMakeLists 的显式列表,返回实际插入项。
只做定点插入、不重排不改写其余内容:uvprojx 由 UV4 维护,任何无关改动都会污染
diff(AGENTS 铁律"UV4 抖动不入库");已存在则不动(幂等,重复跑不产生 diff)。
"""
inserted = []
# ① uvprojx:Drivers/BSP/UI 组的 <Files> 末尾补 <File> 块
uv = _read_text(UVPROJX)
gi = uv.find('<GroupName>%s</GroupName>' % UI_GROUP)
if gi < 0:
sys.exit('ERROR: uvprojx 找不到组 %r,无法自动注册(请检查工程结构)' % UI_GROUP)
fe = uv.find('</Files>', gi)
if fe < 0:
sys.exit('ERROR: uvprojx 的 %s 组没有 </Files>,无法自动注册' % UI_GROUP)
line_start = uv.rfind('\n', 0, fe) + 1
add = ''
for fn in filenames:
if '<FilePath>..\\Drivers\\BSP\\UI\\%s</FilePath>' % fn in uv:
continue
add += (' <File>\n'
' <FileName>%s</FileName>\n'
' <FileType>1</FileType>\n'
' <FilePath>..\\Drivers\\BSP\\UI\\%s</FilePath>\n'
' </File>\n' % (fn, fn))
inserted.append(('uvprojx', fn))
if add:
uv = uv[:line_start] + add + uv[line_start:]
_write_text(UVPROJX, uv)
# ② CMakeLists:set(UI_SOURCES ...) 列表末尾补 ${UI_DIR}/xxx.c
cm = _read_text(CMAKELISTS)
si = cm.find('set(UI_SOURCES')
if si < 0:
sys.exit('ERROR: CMakeLists.txt 找不到 set(UI_SOURCES ...),无法自动注册')
ce = cm.find('\n)', si)
if ce < 0:
sys.exit('ERROR: CMakeLists.txt 的 UI_SOURCES 没有行首 ) 收尾,无法自动注册')
line_start = ce + 1
add = ''
for fn in filenames:
if '${UI_DIR}/%s' % fn in cm[si:ce]:
continue
add += ' ${UI_DIR}/%s\n' % fn
inserted.append(('CMakeLists', fn))
if add:
cm = cm[:line_start] + add + cm[line_start:]
_write_text(CMAKELISTS, cm)
return inserted
def main():
if not os.path.exists(SRC):
sys.exit('ERROR: 未找到 ui_menu_tree.txt(应在仓库根目录)')
text = open(SRC, encoding='utf-8-sig').read()
nodes, directives = parse_tree(text)
if len(nodes) > MAX_NODES:
sys.exit('ERROR: 节点数 %d 超过 uint8_t 索引上限 %d(哨兵 MENU_ROOT=0xFF 占用 255);请精简菜单或改回 16 位索引' % (len(nodes), MAX_NODES))
unknown = sorted(set(directives) - {'password'})
if unknown:
sys.exit('ERROR: 未知指令 @%s(当前仅支持 @password=<页面模块名>)' % ', @'.join(unknown))
bound, password_stem = validate_pages(nodes, directives)
header, body, menus, max_depth = emit(nodes)
reg_h, reg_c = emit_registry(bound, password_stem)
# Drivers/BSP/UI/* 约定为 UTF-8 带 BOM(同 ui_menu.c);字符串字面量含中文,
# 无 BOM 时 cl.exe(代码页 936) 会按 GBK 误读 UTF-8 字节导致字符串解析错乱。
for path, content in ((OUT_H, header), (OUT_C, body),
(OUT_REG_H, reg_h), (OUT_REG_C, reg_c)):
with open(path, 'w', encoding='utf-8-sig', newline='') as fp:
fp.write(content)
# 双端注册:注册表本身 + 每个绑定页实例(密码页也必须是已绑定或 @password 声明的)
needed = ['ui_page_registry.c'] + sorted({'ui_page_%s.c' % p for _, _, p in bound})
pruned = prune_registration() # 先清死引用,再补缺目(改名场景两头都覆盖)
inserted = sync_registration(needed)
protected = sum(1 for _, _, _, p, _ in nodes if p)
print('nodes=%d menu=%d leaf=%d protected=%d max_depth=%d'
% (len(nodes), menus, len(nodes) - menus, protected, max_depth))
print('pages bound=%d password=%s' % (len(bound), password_stem or '(none)'))
print('->', os.path.relpath(OUT_H, ROOT))
print('->', os.path.relpath(OUT_C, ROOT))
print('->', os.path.relpath(OUT_REG_H, ROOT))
print('->', os.path.relpath(OUT_REG_C, ROOT))
for target, fn in pruned:
print(' pruned %-12s %s' % (target, fn))
for target, fn in inserted:
print(' registered %-12s %s' % (target, fn))
if not pruned and not inserted:
print(' registration up-to-date (uvprojx + CMakeLists unchanged)')
if __name__ == '__main__':
main()