/* * 模块名称: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 #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; } }