Files
wandering1 7e15f49ff3 refactor(编号): UI 菜单层级 L0~L3 → 主界面/一级/二级/三级菜单(批次 3a)
这族是菜单深度号:L0 根、L1 一级、L2 二级、L3 三级。用户 2026-10-28:「编号看不懂」,
且与采集链分层的 L1~L5 撞形(同一份文档里两套 L 号,读的人得先猜是哪套)。

改法(生成物走生成器,不手改):
- `tools/menu_gen/gen_menu.py`:加 MENU_LEVEL_NAMES 常量,生成 ui_menu_tab.c 的注释从
  `/* 12 L2 */` 变成 `/* 12 二级菜单 */`;重跑生成器,生成物 42 行注释随之更新。
- 文档 4 篇 113 行:菜单页面设计(94)、内容页架构(10)、01_主界面_说明(8)、
  中文字体生成与配置(1)。映射:L0 → 主界面(根)、L1/L2/L3 → 一级/二级/三级菜单;
  `4 级(L0~L3)` → `4 级(主界面 → 三级菜单)`;括号里的 `(L1)` → `(一级菜单)`。

范围纪律:只动 UI 菜单这一族。同名的测试层级族(L0/L0.5/L1/L2/L3,见测试开发标准流程)
与链路延迟分段 L1~L8 不动——那两个属批次 3 的另两族。

验证:@build 0 错 0 警;check_menu_rows 8 状态 PASS(5368 B 池不变);生成器重跑幂等
(rc=0,生成物只剩注释差异 42/42 行);doc-links 通过(195 文档);mojibake / bom-guard /
新编号门禁 / git diff --check 全绿。
2026-10-28 13:12:06 +08:00

703 lines
33 KiB
Python
Raw Permalink 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 宏
# 菜单层级语义名(索引 = 深度)。过去生成物注释里写 L0~L3,用户 2026-10-28 裁定改语义名——
# 生成物里的层号人看不懂,且与"采集链分层"的 L1~L5 撞形。
MENU_LEVEL_NAMES = ("主界面", "一级菜单", "二级菜单", "三级菜单",
"四级菜单", "五级菜单", "六级菜单", "七级菜单")
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)调用方零代码改动。',
' */',
"",
"/*",
" * 函数功能:取某节点的直接子项(按表序)",
" * 入口参数:node - 节点下标 uint8_t 0 - 表长-1",
" * out - 子项下标输出 uint8_t[] 长度 >= max,可为 NULL(只数个数时)",
" * max - 最多写出个数 uint8_t >= 1",
" * 返回值:实际写出的子项个数 uint8_t 0 - max(0 = 叶子节点)",
" * 限定条件:node 越界时返回 0,不写 out(与\"叶子\"同形,调用方需要区分就先用 has_children)",
" * 函数说明:不分配内存、不递归,只查一层。",
" */",
"uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max);",
"",
"/*",
" * 函数功能:判断节点是否为菜单节点(有子项)",
" * 入口参数:node - 节点下标 uint8_t 0 - 表长-1",
" * 返回值:true - 有子项(显示为菜单,带 ▶);false - 无子项(叶子,可进内容页)",
" * 限定条件:纯查询,无副作用;node 越界返回 false",
" * 函数说明:这是**谓词**(bool 合法),不是成败码——bsp-err 的 err-type 检查里白名单放行的形态。",
" */",
"bool ui_menu_tab_has_children(uint8_t node);",
"",
"/*",
" * 函数功能:取节点的显示名",
" * 入口参数:node - 节点下标 uint8_t 0 - 表长-1",
" * 返回值:显示名字符串指针 const char* 指向表内静态存储,**不为 NULL**(越界返回空串)",
" * 限定条件:返回的是静态存储地址,调用方不得写、不得 free",
" * 函数说明:编码随表来源(ui_menu_tree.txt),显示层直接喂 LVGL。",
" */",
"const char *ui_menu_tab_name(uint8_t node);",
"",
"/*",
" * 函数功能:判断该叶子是否需要密码",
" * 入口参数:node - 节点下标 uint8_t 0 - 表长-1",
" * 返回值:true - 需密码(先进密码页,按所输密码授权);false - 直接进(只读)",
" * 限定条件:纯查询;node 越界返回 false(保守取\"只读\",不放开写权限)",
" * 函数说明:授权强度由密码页所输密码决定,本函数只回答\"要不要过这一关\"。",
" */",
"bool ui_menu_tab_has_pwd(uint8_t node);",
"",
"/*",
" * 函数功能:菜单表启动自检(表与生成器是否自洽)",
" * 入口参数:无",
" * 返回值:true - 表自洽(非根节点的 parent 下标均有效、pwd 只取 0/1);",
" * false - 表被手工改坏,调用方应拒绝进入菜单而不是继续用",
" * 限定条件:ui_menu_init() 阶段调用一次",
" * 函数说明:宁可拒绝显示,也不要按一张错表导航——错表可能把只读叶子指到写页面上。",
" */",
"bool ui_menu_tab_valid(void);",
'',
'#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'
# 层级用语义名(2026-10-28 用户裁定:L0~L3 这套层号看不懂,改成语义层名)
c.append(' { %8s, %2s, "%s" }, /* %2d %s */'
% (pstr, cstr, name, i, MENU_LEVEL_NAMES[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()