Files
STM32F4-Base/Drivers/BSP/UI/ui_menu.c
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

286 lines
13 KiB
C
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.
/*
* 模块名称:UI Menu (菜单导航逻辑层)
* 模块功能:g_menu_tab 父子索引表驱动的横向级联菜单导航——持有导航状态
* (路径/选中/深度/滚动,共 17 B)与键语义(↑↓移选、→/ENTER 进、←/ESC 退),
* 单向驱动显示层 ui_menu_view 建/毁/刷新级联框;本层不持有任何 LVGL 对象
* 适用平台:STM32F407ZGTx / PC 模拟器(两端共用)
* 作者:王建锋
* 创建日期:2026-08-29
* 修改记录:
* 2026-09-04 王建锋 重构为"横向级联框"模型(菜单页面设计.md):拆出显示层
* ui_menu_view 与数据层 ui_menu_tab;本层只留导航状态机
* 2026-09-08 王建锋 按代码规范 V1.0 补全分区注释与关键行注释;函数定义括号改 K&R 风格(同行)
* 2026-09-08 王建锋 4 层容器→单一持久容器(焦点/键唯一入口;键回调深度改取
* s_depth,去掉 user_data=depth;container/focus 调用去参)
* 2026-09-09 王建锋 密码页路由:需密码叶子(ui_menu_tab_has_pwd)ENTER
* → 先密码页;确认一致由 menu_password_ok 续做原"进入"动作
* (内容屏/子框)
* 2026-09-09 王建锋 权限模型:密码检查提到内容页判断之前(带内容页的受保护
* 叶子也先经密码页,修复"内容页叶子绕过密码"路由缺口);表 pwd
* 列改布尔"需密码"(行尾 #P);密码页按所输密码授权(查看→只读
* / 修改→可修改),授权经回调随开页传给内容页(页面经
* is_editable 判编辑);无密码叶子直接进、默认只读
* 2026-09-14 王建锋 运行记录/自检记录内容页映射(node 40/39)+ 键事件挂钩待机空闲通知
*/
/* 头文件包含区 */
#include <stdint.h>
#include "ui_menu.h"
#include "ui_menu_view.h"
#include "ui_content_page.h"
#include "ui_page_registry.h"
#include "ui_page_password.h"
#include "ui_standby.h"
#include "ui_menu_tab.h"
#include "ui_pwd_def.h"
#include "lvgl.h"
#define DBG_TAG "[UI_MENU]"
#include "dbg_log.h"
/* 静态变量定义区 */
/*
* 导航状态:唯一"必须持久"的导航历史(共 17 B,见设计 §7.2);
* 条目列表/框几何均可由 g_menu_tab + 本状态随时重算,不落 RAM
*/
static uint8_t s_path[MENU_PAGE_MAX]; /* 路径:s_path[d]=第 d 层展示节点(s_path[0]=0 主界面) */
static uint8_t s_sel[MENU_PAGE_MAX]; /* 各层选中下标(返回时还原,选中不跳) */
static uint8_t s_depth; /* 当前深度(0=根框)= 焦点所在(最右)框 */
static uint16_t s_scroll[MENU_PAGE_MAX]; /* 各框滚动偏移(px);框销毁再进入时还原 */
/* 函数定义区 */
/*
* 函数功能:移选后的层刷新(高亮 + 跟随滚动 + 滚动位回读持久化)
* 入口参数:depth - 层深度 uint8_t
* 返回值:无
* 限定条件:该层框已建
* 函数说明:显示层跟随滚动后,滚动位写回 s_scroll 持久(框重建时还原)
*/
static void menu_refresh_layer(uint8_t depth) {
ui_menu_view_frame_refresh(depth, s_sel[depth]);
s_scroll[depth] = ui_menu_view_get_scroll(depth);
}
/*
* 函数功能:进入选中条目(压层)
* 入口参数:child - 选中子节点下标 uint8_t
* 返回值:无
* 限定条件:s_depth+1 < MENU_PAGE_MAX
* 函数说明: 1. 叶子(无子项)同样压层,生成空白终点框(占位,无条目)
* 2. 同节点再次进入:还原上次选中与滚动位;换节点则清零
*/
static void menu_open(uint8_t child) {
uint8_t d = s_depth;
bool same = (s_path[d + 1u] == child);
s_depth = (uint8_t)(d + 1u);
s_path[d + 1u] = child;
s_sel[d + 1u] = same ? s_sel[d + 1u] : 0u;
ui_menu_view_frame_build(d + 1u, child, s_sel[d + 1u],
(uint16_t)(same ? s_scroll[d + 1u] : 0u));
ui_menu_view_focus();
}
/*
* 函数功能:关闭最右框(弹层)
* 入口参数:无
* 返回值:无
* 限定条件:s_depth > 0
* 函数说明: 1. 销毁最右框(框体+行数据;既有屏上框不动、无需重建)
* 2. 焦点回父框;父层选中/滚动原样保留(选中不跳)
*/
static void menu_back(void) {
ui_menu_view_frame_destroy(s_depth);
s_depth--;
ui_menu_view_focus();
}
/*
* 函数功能:密码页确认一致回调(预装进密码页模块;uiTask 上下文调用)
* 入口参数:perm - 密码页按所输密码授予的权限 ui_perm_t(UI_PERM_MODIFY/UI_PERM_VIEW)
* 返回值:无
* 限定条件:密码页打开中;导航状态未变(选中条目仍为该受保护叶子)
* 函数说明: 1. 先关密码页(焦点回菜单容器、切回菜单屏;与 ESC 路径同模式,
* 事件内删对象由 LVGL e->deleted 机制保证安全)
* 2. 再沿未变的导航状态续做"进入"动作:有内容页 → 开内容页(携授予的
* 权限;页面经 is_editable 判只读/可编辑);否则压层进子框
* (叶子 → 空白终点框,权限不涉及)
* 3. 防御:状态异常 / 最深层无子层可压时仅停在菜单屏(页已关)
*/
static void menu_password_ok(uint8_t perm) {
ui_content_page_close(); /* 先关密码页回菜单屏,后续动作才不会落在被盖住的菜单屏上 */
uint8_t depth = s_depth;
uint8_t kids[MENU_ITEM_MAX];
uint8_t cnt = ui_menu_tab_children(s_path[depth], kids, MENU_ITEM_MAX);
if (cnt == 0u || s_sel[depth] >= cnt) {
return; /* 防御:状态异常,页已关,停在菜单屏 */
}
{
uint8_t child = kids[s_sel[depth]];
const content_page_def_t *page = ui_page_registry_for(child);
if (page != NULL) {
ui_content_page_open(page, perm); /* 页已关,守卫可过:开目标内容页(携权限) */
}
else if (depth < MENU_PAGE_MAX - 1u) {
menu_open(child); /* 压层:建新框 + 焦点移到新框(叶子 → 空白终点框) */
}
/* 最深层无子层可压:页已关,停在菜单屏 */
}
}
/*
* 函数功能:按键语义分发(移/进/退)
* 入口参数:depth - 焦点所在层深度 uint8_t
* key - LVGL 键值 lv_key_t
* 返回值:无
* 限定条件:ui_menu_init() 已完成
* 函数说明: 1. UP/DOWN:移选(首尾环绕);选中越出可见区由显示层跟随滚动
* 2. ENTER/RIGHT:需密码叶子(含带内容页者)→ 密码页(授权按所输
* 密码:查看→只读 / 修改→可修改,menu_password_ok 携权续进入);
* 否则叶子有内容页 → 切内容屏(不压层,默认只读);
* 再否则进子框(叶子 → 空白终点框)
* 3. ESC/LEFT:关最右框(根框不响应)
* 4. 空框(终点页 0 条目)只响应返回
*/
static void menu_key_handle(uint8_t depth, lv_key_t key) {
uint8_t kids[MENU_ITEM_MAX]; /* 本框条目列表:由 g_menu_tab 按 parent 现算,不落 RAM */
uint8_t cnt = ui_menu_tab_children(s_path[depth], kids, MENU_ITEM_MAX); /* 条目数;0 = 空白终点框(进入叶子) */
switch (key) {
case LV_KEY_UP:
if (cnt > 0u) { /* 空框(0 条目)不移选;同时防对 0 取模 */
/* 移选上移、首尾环绕:先加 cnt 再减 1 保证无符号下不为负,% cnt 绕回首行 */
s_sel[depth] = (uint8_t)((s_sel[depth] + (uint8_t)cnt - 1u) % cnt);
menu_refresh_layer(depth); /* 刷新高亮 + 跟随滚动 + 滚动位回读持久化 */
}
break;
case LV_KEY_DOWN:
if (cnt > 0u) { /* 空框(0 条目)不移选;同时防对 0 取模 */
/* 移选下移、首尾环绕:% cnt 越尾自动绕回首行 */
s_sel[depth] = (uint8_t)((s_sel[depth] + 1u) % cnt);
menu_refresh_layer(depth); /* 刷新高亮 + 跟随滚动 + 滚动位回读持久化 */
}
break;
case LV_KEY_ENTER:
case LV_KEY_RIGHT: {
/* 进子框:空框无条目可进;最深层(depth=MAX-1)无子层可再开 */
if (cnt > 0u) {
uint8_t child = kids[s_sel[depth]];
const content_page_def_t *page = ui_page_registry_for(child);
if (ui_menu_tab_has_pwd(child)) {
/* 需密码叶子(含带内容页者):先经密码页;授权按所输密码
(查看→只读 / 修改→可修改),回调携权续做"进入"动作;
密码页非节点绑定,经注册表专用出口取 */
ui_page_password_arm(menu_password_ok);
ui_content_page_open(ui_page_registry_password(), (uint8_t)UI_PERM_VIEW);
}
else if (page != NULL) {
/* 叶子有内容页:无密码直接进(默认只读权限) */
ui_content_page_open(page, (uint8_t)UI_PERM_VIEW);
}
else if (depth < MENU_PAGE_MAX - 1u) {
menu_open(child); /* 压层:建新框 + 焦点移到新框(叶子 → 空白终点框) */
}
}
break;
}
case LV_KEY_ESC:
case LV_KEY_LEFT:
if (depth > 0u) { /* 根框(d=0)不响应返回 */
menu_back(); /* 弹层:毁最右框,焦点回父框(选中/滚动不跳) */
}
break;
default:
break; /* 其余键值忽略 */
}
}
/*
* 函数功能:页面容器按键事件入口(唯一入口;深度取 s_depth)
* 输入参数:e - 事件指针 lv_event_t *
* 返回值:无
* 限定条件:容器已加入默认输入组并获焦
* 函数说明:键值经默认输入组送达焦点容器;统一分发到 menu_key_handle
* (焦点恒为持久容器,焦点框深度即 s_depth,无需 user_data 传层)
*/
static void menu_key_cb(lv_event_t *e) {
ui_standby_notify_activity(); /* 用户活动:重置待机空闲计时 */
menu_key_handle(s_depth, (lv_key_t)lv_event_get_key(e));
}
/*
* 函数功能:构建菜单(横向级联框)
* 入口参数:无
* 返回值:0 - 成功,其他 - 失败(表自检失败/对象创建失败)
* 限定条件:ui_port_init()/sim_port_init() 已成功调用,活动屏幕存在
* 函数说明: 1. 数据表自检(parent 下标均有效,捕获表内笔误)
* 2. 重置导航状态 → 初始化显示层
* 3. 持久容器加入默认输入组并挂键回调(唯一入口;深度取 s_depth)
* 4. 建根框(主界面 node 0 子项)并置焦点
* 5. 构建前后监控 LVGL 池内存并打印
*/
int ui_menu_init(void) {
/* 数据表自检:各节点 parent 下标必须有效,提前捕获表内笔误(失败即返回、不建任何对象) */
if (!ui_menu_tab_valid()) {
DBG_ERROR("menu table: INVALID (parent index out of range)");
return -1;
}
/* 重置导航状态:各层从主界面(node 0)第 0 行开始、无滚动 */
for (uint8_t d = 0u; d < MENU_PAGE_MAX; d++) {
s_path[d] = 0u;
s_sel[d] = 0u;
s_scroll[d] = 0u;
}
s_depth = 0u; /* 起始于根框 */
/* 取 LVGL 池内存基线(与构建后对照,量化菜单对象开销) */
lv_mem_monitor_t mon;
lv_mem_monitor(&mon);
DBG_INFO("menu build start: total=%u free=%u big=%u",
(unsigned)mon.total_size,
(unsigned)mon.free_size, (unsigned)mon.free_biggest_size);
/* 初始化显示层:样式 + 持久容器 + 根框框体(失败 = 对象创建失败、池耗尽) */
if (ui_menu_view_init() != 0) {
return -1;
}
/* 键接线:持久容器入默认输入组;按键一律由逻辑层处理(显示层纯渲染) */
lv_obj_t *cont = ui_menu_view_container();
if (cont == NULL) {
return -1;
}
lv_group_add_obj(lv_group_get_default(), cont); /* 容器入组:焦点可轮到它、能收键事件 */
lv_obj_add_event_cb(cont, menu_key_cb, LV_EVENT_KEY, NULL);
/* 根框:主界面(node 0)子项;建一次、持久 */
ui_menu_view_frame_build(0u, 0u, 0u, 0u); /* depth=0、node=0(主界面)、sel=0、scroll=0 */
ui_menu_view_focus(); /* 焦点置持久容器:按键进入菜单 */
/* 读构建后内存、打印增量(对照上方基线) */
lv_mem_monitor(&mon);
DBG_INFO("menu build done: total=%u free=%u big=%u used=%u",
(unsigned)mon.total_size,
(unsigned)mon.free_size, (unsigned)mon.free_biggest_size,
(unsigned)(mon.total_size - mon.free_size));
return 0;
}