493fa6ba0e
STATUS(命令 4)一帧回答"现在在哪个画面":kind(MENU/PAGE/STANDBY) + 菜单路径/选中 + 页 id,布局契约与字节序见 ui_net_sink.h。由 uiTask 轮询驱动,同上下文读静态导航状态:零画面副作用、免锁、不发日志。 新增只读接口 ui_menu_get_nav_state / ui_content_page_current_desc。 GOTO(命令 5)按页 id 查生成表直开内容页,以 VIEW 权限进入(与 "已过查看密码"同档,不代输任何密码)。编译期门控 DTU_BENCH || UI_REMO_ALLOW_GOTO:产品构建恒拒(status=4),模拟器 构建默认开启便于无板验证;执行类页(clear/reset)固件侧同样拒绝 (status=2,与主机 EXEC_PAGE_IDS 同一事实源);待机中拒绝(status=5), 由主机按键唤醒后再来。成功应答带回目标叶节点下标便于对账。 ui_sink 主机单测补导航侧链接假件 + 最小 lvgl 桩(仅本测试目录)。 产品构建 Keil 0 Error 0 Warning;ui_sink 单测 17/17。
306 lines
14 KiB
C
306 lines
14 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;
|
||
}
|
||
|
||
/*
|
||
* 函数功能:读取菜单导航状态(契约见 ui_menu.h,Issue #69)
|
||
* 入口参数:p_depth / p_path / p_sel - 三个只读出参,任一可为 NULL 表示不取
|
||
* 返回值:无
|
||
* 限定条件:同上下文(uiTask)读取;本函数零副作用、不打日志(每帧查询不刷日志)
|
||
* 函数说明:直接交还静态数组指针(导航状态本身仅 17 B,拷贝反而是第二份要同步的
|
||
* 事实);消费方按"深度+1"项数读取,越界由消费方的深度钳位负责
|
||
*/
|
||
void ui_menu_get_nav_state(uint8_t *p_depth, const uint8_t **p_path, const uint8_t **p_sel) {
|
||
if (p_depth != NULL) {
|
||
*p_depth = s_depth;
|
||
}
|
||
if (p_path != NULL) {
|
||
*p_path = s_path;
|
||
}
|
||
if (p_sel != NULL) {
|
||
*p_sel = s_sel;
|
||
}
|
||
}
|