7e15f49ff3
这族是菜单深度号: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 全绿。
703 lines
33 KiB
Python
703 lines
33 KiB
Python
#!/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()
|