dafc18f5ed
启动 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 改同一批文件、文件内
交叠,硬切会产生不可编译的中间态,故整波一笔。
286 lines
13 KiB
C
286 lines
13 KiB
C
/*
|
||
* 模块名称: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;
|
||
}
|