Files
STM32F4-Base/docs/UI设计/菜单页面设计.md
T
wandering1 d64bbdc57f feat(wave): 故障录波数据面、接线与单通道回放页(软件闭环交付)
数据面(App/algo,纯逻辑,双端编译)
- wave_ring:8 路 × 320 点列优先环(1 kHz,预触发 160 + 后触发 160),adcTask
  唯一写者;触发/冻结/封存/冷却四态,冻结持续到落盘回执才解冻,滞留 2 s 自弃
  兜底——下一次真故障无波可录比丢一段更贵
- wave_store:段头编解码 + 拉模式打包游标 + 自带无表位运算 CRC-16/MODBUS(带
  锚点自检)+ 段名与整段长度等式;格式不依赖平台类型宽度
- 段头只存原始码值与封存瞬间生效系数,工程量只当单位标签、不进任何算式
- 三套主机单测(环 113 / 编解码 170 / 触发状态机 110),全部无桩、生产源原样
  纳入;新文件补 stddef 是 ARMCC 与主机差异,主机侧照不出这条分叉
- test/host/wave_read.py 是按 §6.2 的独立第二实现(dump/fft/shape/plot),92 条
  自洽断言;三份合成段互读双校验全过、哈希钉死,格式面双向闭合
- 三套新单测不加专属钩子,由覆盖率总闸全量真跑(沿用既有裁定)

接线(任务层)
- adc_cal:新增生效系数的 32 位定点原值入口(与既有浮点入口共用生效视图),
  段头按最低有效位精确记账,不经浮点回算
- adcTask:每拍压 8 路语义原始码值并推进状态机,封存那一拍同拍交棒封存瞬间事实;
  台架定周期自抓走双层编译门,产品镜像里零存在;串口新增一条 wave: 周期行
- defaultTask:卷空闲后一次心跳整段搬完(try 锁 + 256 字节分片 + 一次完整开档到
  关档),成败都回执;失败先删半成品再计错,段号只在成功时推进;持锁超 50 ms
  补一条告警并记历史峰值(喂狗余量可核)
- Lead 复核补第四道并发陷阱:采集任务优先级高于落盘道,搬段中途它可能正好把滞留
  超时的本段自弃、甚至封出下一段——数据区校验和是对「实际读到的字节」算的,
  半新半旧的段照样自洽,故关档后复检环侧身份判废:删文件、不补账、段号不推进
- Lead 复核抓到触发挂号不是原子操作(旧实现分三个字节写,消费者可在窗口里读到
  上一段的触发源与元件号):已把载荷压进一个字节,越界当场拒且不记任何计数;
  段文件字节布局零改动,三份合成段哈希逐字节不变
- protectTask:保护启动上升沿同点请求录波,一拍一次、元件号取最低位;判据本体
  零改动

人机(160×160 单色屏)
- 菜单树节点 26「手动录波」绑页面 + 记录查询组末新增「录波回放」叶,重跑生成器
  得 43 节点 / 34 叶 / 30 已绑,页面映射 28 → 30
- 回放页一屏一路、触发点前后各一周波共 40 点、每点 3 像素阶梯线(只用水平与
  垂直两种线段——单色屏斜线会被阈值裁掉);零点虚线、触发光标、窗口峰值、
  单位徽标与形状受限徽标;空态与坏段各一句显式文案,绝不用半截波形冒充数据
- ui_data 新增录波注入通道(六件操作表、三态返回);执行侧胶水带段头缓存、
  手动请求后连缓存一起作废、逐点走全仓唯一的码值换算入口
- 模拟器接真实环与编解码合成两段(含坏段与空态旗标),6 张新黄金参考,既有
  60 张逐字节零漂移未用更新模式;字库 218 → 224 字 / 319 字形
- 两份图形配置里图表组件仍为关;零堆分配;页面池水位不变
- 回放读路径的 uiTask 阻塞上界按实码写清:一次渲染 = 段表刷新 + 未缓存段头最坏
  22 次小读;已验段只剩一次开窗读 + 40 点解码;无实测数不编,实测归台架、
  仪器用已在跑的单轮工作耗时探针,不新加埋点

台账与文档
- 内存台账:录波环与落盘/回放静态逐条入册,三档对照 81,536 → 88,296 → 89,936
  字节(62.2% → 68.6%),累计 +8,400;预估 5.9 KB 与实测 +6,760 的差额点名;
  散列文件头注释同步;任务栈表核对后无需抬栈;体积趋势行与同码凭证由权威构建
  重打(0 错误 0 警告,四项体积与上一轮相同——本轮改动只有注释与声明位置)
- 设计说明:§5 补第四条并发陷阱;§6.3 定字段口径(两份独立实现的共同依据);
  §7 回填实测;§9 周期行写实;§10 台架判据改两段式且不比绝对相位;§12 补读路径
  风险行;抬头状态改成「代码面已交付、未做完的只有板级那一轮」
- 卡片:软件闭环一栏列证据;台架三条按用户裁定保持未完成,不做虚假销项
- 口径同步:数据可信、UI 补齐、页面补齐规划、产品需求、采集短板评测、任务规划
  索引与两份 README;陈旧现状句清扫 16 处(带日期戳的历史取数行一律不动)
- 门禁说明:nested-comment 记仓库根探测必须兼容 linked worktree 的 gitdir 文件
  形态(修前该钩子在任务树里恒报环境错、静默不可用);coding-standard 记
  「函数头注释超过 24 行就把函数功能挤出回看窗口」这条机制与本次现场
- 覆盖率基线登记两个新覆盖模块(套件 41 → 44 套);环单测重打后该文件有效行
  227 → 226、覆盖行 226 → 225,比率仍 99.6%,是分母变小不是覆盖率回退

为什么是单一提交:文档链接、双端注册、生成器幂等(菜单与字库)、内存数字与
体积事实源、编号门禁这五道整仓门禁要求代码、页面、菜单树、字库、文档与台账
同处一个提交才自洽,按数据面/接线/人机/台账拆成四笔必然红其中至少四道

编号门禁新增行 14 处命中已按语义改写:魔数常量按字符命名、段名样例改由格式串
生成、频谱表头改 f / 2x / 5x 并加一行图例;改写后自洽断言 92 条仍全过
2026-10-29 19:23:15 +08:00

872 lines
56 KiB
Markdown
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.
# 菜单页面设计
> **用途**:定义 DTU 主菜单的**内容(42 节点层级树)、视觉规范、三层代码架构**(数据层 / 显示层 / 逻辑层),以及叶子节点进入后的**内容页模块**(§九)。
> **范围**:菜单层级(节点/父子/层级/进出导航)+ 三个已实现的内容页(装置信息/交流量/直流量,§九);其余叶子的内容页后续按同模式扩展。
> **来源**:用户提供的主菜单截图 7 屏(根页 + 实时数据/参数定值/三遥设置/装置维护/通讯参数/记录查询各一级子页 + 参数定值·整定二级)。
> **导航模型**:树形**逐级进入**——`ENTER`/`→` 进入子页,`ESC`/`←` 返回上一级;最多 **4 级(主界面 → 三级菜单)**。
> **阅读指南**:
>
> | 你想… | 读 |
> |---|---|
> | 搞懂**哪段代码干什么、三层怎么配合** | **§一 架构总览**(职责速览 / 调用矩阵 / 一次按键的旅程 / 初始化时序 / 层间不变量),再按需深入 §三 / §五 / §六 |
> | 深入某一层 | §三 数据层 / §五 显示层 / §六 逻辑层(各章自包含:职责边界 + 字段 + API) |
> | **改菜单**(增删/改名/换序条目) | §二(内容)+ §3.7(只改文本源、重跑生成器,代码零改动) |
> | **改视觉**(框/行/几何) | §四(显示规范)+ §五(显示层实现) |
> | **内容页**(装置信息/交流量/直流量) | §九(对象树/生命周期/页面定义表/API/内存预算) |
> | 搞懂**内存**(什么存、什么现算) | §7.3 + §7.5 |
> | **验证 / 调试** | §八 |
---
## 一、架构总览:三层拆分与协作
### 1.1 为什么拆三层
"菜单表 + 导航状态 + LVGL 渲染" 三块职责若同处一个文件则互相耦合(改菜单要动导航代码、改渲染易破坏导航)。因此拆为三层,**一层一文件、各管一件事**:
- **数据层**——只回答"**有什么条目**":只读菜单表 + 4 个查询 API;
- **显示层**——只回答"**怎么画**":持有**全部** LVGL 对象,**零**导航状态;
- **逻辑层**——只回答"**用户在哪、选中哪个**":唯一 17 B 导航状态 + 键语义(移/进/退)。
3 个文件 **MCU / PC 模拟器双端共用**(双端注册:Keil `.uvprojx` + `simulator/CMakeLists.txt`,见 `UI_设计架构.md` §1.4 约束 5)。
### 1.2 三层职责速览
```mermaid
flowchart TD
K(["按键(远端键 / 模拟器键)"]) --> L["逻辑层 ui_menu.c<br/>唯一 17 B 导航状态(path/选择/深度/滚动)<br/>键语义:移动 / 进入 / 退出"]
L -- "单向驱动: frame_build / frame_destroy / frame_refresh / focus" --> V["显示层 ui_menu_view.c/h<br/>持全部 LVGL 对象,纯渲染,零导航状态"]
L -- "只读查询: children / valid" --> T["数据层 ui_menu_tab.c/h(生成物,只读)<br/>g_menu_tab[42] 父子索引表 + 4 个查询 API"]
V -- "只读查询: children / has_children / name" --> T
```
| 层 | 文件 | 一句话职责 | 持 LVGL 对象? | 持导航状态? |
|---|---|---|---|---|
| **数据层** | `Drivers/BSP/UI/ui_menu_tab.c/h`(生成物) | 回答"有什么条目":菜单表 + 查询 API | 否 | 否 |
| **逻辑层** | `Drivers/BSP/UI/ui_menu.c/h` | 回答"用户在哪、选中哪个":导航状态 + 键语义 | 否 | **是(唯一,17 B)** |
| **显示层** | `Drivers/BSP/UI/ui_menu_view.c/h` | 回答"怎么画":级联框渲染 | **是(全部)** | 否 |
> 记忆口诀:**数据层管"料",逻辑层管"账",显示层管"画"**。
### 1.3 依赖方向与调用矩阵
依赖**严格单向**:`逻辑 → 显示 → 数据`,数据层不依赖任何人;**禁止反向调用**(显示层不调逻辑层;数据/显示层不引用导航状态)。
| 调用方 ↓ / 被调 → | 数据层 | 显示层 | 逻辑层 |
|---|---|---|---|
| **数据层** `ui_menu_tab` | — | ✗ | ✗ |
| **显示层** `ui_menu_view` | ✓ 查询 API | — | ✗ |
| **逻辑层** `ui_menu` | ✓ 查询 API | ✓ 8 个显示接口 | — |
层间"词汇"只有两组接口(各层章节有完整签名):
| 方向 | 接口 |
|---|---|
| 逻辑 → 显示 | `ui_menu_view_init / container / frame_build / frame_destroy / frame_refresh / set_scroll / get_scroll / focus`(§5.5) |
| 逻辑/显示 → 数据 | `ui_menu_tab_children / has_children / name / valid`(§3.4) |
### 1.4 按键协作:一次按键的旅程
```mermaid
sequenceDiagram
participant K as 按键(远端键 / 模拟器键)
participant G as LVGL 默认输入组(lv_group)
participant L as 逻辑层 ui_menu.c
participant V as 显示层 ui_menu_view
participant T as 数据层 ui_menu_tab
K->>G: 键码
Note over G: 焦点 = 持久容器 s_cont(唯一键入口;帧深度由逻辑层 s_depth 决定)
G->>L: LV_EVENT_KEY → menu_key_cb(深度=s_depth)→ menu_key_handle
L->>L: 状态变更:移动 s_sel[d] / 进入 s_depth+1+s_path / 退出 s_depth−1
L->>V: frame_refresh(高亮+跟滚)/ frame_build(建新框)/ frame_destroy(拆最右框)+ focus(移焦点)
V->>T: children / has_children / name(条目现算不落盘)
V-->>L: get_scroll(读回滚动位置,逻辑层写入 s_scroll[d])
Note over V: LVGL 下一帧渲染 → 屏幕变化
```
两个要点:
- **逻辑层决定"改成什么状态",显示层决定"画面怎么变"**——显示层只执行、不决策;数据层全程不参与(只被显示层查询条目/名字/菜单叶子)。
- 全程**单向同步**:一次按键 = 一次完整的"状态变更 → 画面更新",无异步、无回调上行。
### 1.5 初始化时序(`ui_menu_init`)
`uiTask` 等 `SYS_EVT_NET_READY` 后调 `ui_menu_init()`,三层按此顺序配合:
```mermaid
sequenceDiagram
participant U as uiTask
participant L as 逻辑层 ui_menu.c
participant V as 显示层 ui_menu_view
participant T as 数据层 ui_menu_tab
U->>L: ui_menu_init()
L->>T: ui_menu_tab_valid() 表自检
alt 父索引越界(表损坏)
T-->>L: 不过 → 打 ERROR、放弃建菜单(返回 -1)
else 自检通过
T-->>L: 通过
end
L->>L: 清零 17 B 导航状态(s_path/s_sel/s_scroll=0,s_depth=0)
L->>V: ui_menu_view_init()(样式 → 屏幕根容器 → 持久容器(装饰/状态行自绘回调) → 根框本体(含自绘回调))
L->>V: 持久容器 lv_group_add_obj(默认组)+ 挂 menu_key_cb(深度取 s_depth)
L->>V: ui_menu_view_frame_build(0) 注册根框行数据 + focus()
Note over L: lv_mem_monitor 打印建前/建后 LVGL 池
L-->>U: 0(成功)
```
- 日志标记:`UI menu init: OK`(uiTask 打印)——`@test` 回归可作 Required 标记。
- LVGL 池实测(模拟器 16 KB 池):根框建完 `used=5368 B`(旧行对象实现为 13312 B,自绘 + 容器收敛省 8.3 KB);最深四级级联 `used=6040 B`(增量 1256 B = 3 个子框体),远小于池余量。
### 1.6 层间不变量(每层"绝不做")
| 层 | 绝不做 | 为什么 |
|---|---|---|
| 数据层 | 业务逻辑(4 个 API 是纯表过滤)、依赖 LVGL、修改表(`const`) | 表是唯一事实源;改表 = 改菜单(走生成器 §3.6) |
| 显示层 | 持导航状态(17 B 全在逻辑层)、解释按键(输入回调挂在逻辑层) | 显示层 = "状态的纯函数":同样状态必画同样画面 → 易验证、双端可复用 |
| 逻辑层 | 创建/修改 LVGL 对象(建对象全在显示层)、持 `lv_obj_t*` 句柄 | 唯一例外:输入接线 2 调 `lv_group_add_obj` / `lv_obj_add_event_cb`(容器须入输入组)+ 读事件参数 + `lv_mem_monitor` 内存监控 |
---
## 二、菜单内容:42 节点层级树
### 2.1 层级总览
主菜单为 4 级树:
| 层级 | 含义 | 例子 |
|---|---|---|
| **主界面(根)** | 主界面,7 个一级条目 | 主界面 |
| **一级菜单** | 根页的 7 个条目 | 装置信息、实时数据、参数定值、三遥设置、装置维护、通讯参数、记录查询 |
| **二级菜单** | 一级子菜单的子项 | 交流量、整定、遥测死区、时钟设置、通讯设置、SOE记录 … |
| **三级菜单** | 仅"参数定值 → 整定/查看"下的子项 | 参数、定值、控制字、软压板 |
节点分两类(决定导航行为):
| 类型 | 标识 | 行为 |
|---|---|---|
| **菜单节点** | 行尾 **▶** 箭头 | `ENTER` 进入 → 展开下一级子页 |
| **叶子节点** | 无箭头 | `ENTER` 进入 → 有内容页(node 1/3/4,§九)则切内容屏;否则进空白终点框 |
> 根页默认选中**第 1 项"装置信息"**(整行反色:白带、黑字)。
### 2.2 完整层级树
```text
主界面
├─ 装置信息 [叶子]
├─ 实时数据 ▶ [菜单]
│ ├─ 交流量 [叶子]
│ ├─ 直流量 [叶子]
│ └─ 遥信量 [叶子]
├─ 参数定值 ▶ [菜单]
│ ├─ 整定 ▶ [菜单]
│ │ ├─ 参数 [叶子]
│ │ ├─ 定值 [叶子]
│ │ ├─ 控制字 [叶子]
│ │ └─ 软压板 [叶子]
│ └─ 查看 ▶ [菜单]
│ ├─ 参数 [叶子]
│ ├─ 定值 [叶子]
│ ├─ 控制字 [叶子]
│ └─ 软压板 [叶子]
├─ 三遥设置 ▶ [菜单]
│ ├─ 遥测死区 [叶子]
│ ├─ 遥测系数 [叶子]
│ ├─ 遥信类型 [叶子]
│ ├─ 遥信防抖 [叶子]
│ └─ 双点遥信 [叶子]
├─ 装置维护 ▶ [菜单]
│ ├─ 时钟设置 [叶子]
│ ├─ 强制复归 [叶子]
│ ├─ 手动录波 [叶子]
│ └─ 清除记录 [叶子]
├─ 通讯参数 ▶ [菜单]
│ ├─ 通讯设置 [叶子]
│ ├─ 网口设置 [叶子]
│ └─ SNTP设置 [叶子]
└─ 记录查询 ▶ [菜单]
├─ SOE记录 [叶子]
├─ 事故记录 [叶子]
├─ 操作记录 [叶子]
├─ 保护告警 [叶子]
├─ 保护启动 [叶子]
├─ 遥控记录 [叶子]
├─ 自检记录 [叶子]
├─ 运行记录 [叶子]
└─ 运行报告 [叶子]
```
**统计**:菜单节点 **9** 个(含根),叶子节点 **33** 个,共 **42** 个节点;最大深度 **4 级**。
### 2.3 节点定义表(逻辑层级定义)
> 扁平表,与 §3.3 生成物 `g_menu_tab[]` 一一对应:`#` = 数组下标,`父节点` = 父菜单的 `#`,`子项数` = 直接子项个数(叶子为 0)。
| # | 名称 | 层级 | 类型 | 父节点 | 子项数 |
|---|---|---|---|---|---|
| 0 | 主界面 | 主界面 | 菜单(根) | — | 7 |
| 1 | 装置信息 | 一级菜单 | 叶子 | 0 | 0 |
| 2 | 实时数据 | 一级菜单 | 菜单 | 0 | 3 |
| 3 | 交流量 | 二级菜单 | 叶子 | 2 | 0 |
| 4 | 直流量 | 二级菜单 | 叶子 | 2 | 0 |
| 5 | 遥信量 | 二级菜单 | 叶子 | 2 | 0 |
| 6 | 参数定值 | 一级菜单 | 菜单 | 0 | 2 |
| 7 | 整定 | 二级菜单 | 菜单 | 6 | 4 |
| 8 | 参数 | 三级菜单 | 叶子 | 7 | 0 |
| 9 | 定值 | 三级菜单 | 叶子 | 7 | 0 |
| 10 | 控制字 | 三级菜单 | 叶子 | 7 | 0 |
| 11 | 软压板 | 三级菜单 | 叶子 | 7 | 0 |
| 12 | 查看 | 二级菜单 | 菜单 | 6 | 4 |
| 13 | 参数 | 三级菜单 | 叶子 | 12 | 0 |
| 14 | 定值 | 三级菜单 | 叶子 | 12 | 0 |
| 15 | 控制字 | 三级菜单 | 叶子 | 12 | 0 |
| 16 | 软压板 | 三级菜单 | 叶子 | 12 | 0 |
| 17 | 三遥设置 | 一级菜单 | 菜单 | 0 | 5 |
| 18 | 遥测死区 | 二级菜单 | 叶子 | 17 | 0 |
| 19 | 遥测系数 | 二级菜单 | 叶子 | 17 | 0 |
| 20 | 遥信类型 | 二级菜单 | 叶子 | 17 | 0 |
| 21 | 遥信防抖 | 二级菜单 | 叶子 | 17 | 0 |
| 22 | 双点遥信 | 二级菜单 | 叶子 | 17 | 0 |
| 23 | 装置维护 | 一级菜单 | 菜单 | 0 | 4 |
| 24 | 时钟设置 | 二级菜单 | 叶子 | 23 | 0 |
| 25 | 强制复归 | 二级菜单 | 叶子 | 23 | 0 |
| 26 | 手动录波 | 二级菜单 | 叶子 | 23 | 0 |
| 27 | 清除记录 | 二级菜单 | 叶子 | 23 | 0 |
| 28 | 通讯参数 | 一级菜单 | 菜单 | 0 | 3 |
| 29 | 通讯设置 | 二级菜单 | 叶子 | 28 | 0 |
| 30 | 网口设置 | 二级菜单 | 叶子 | 28 | 0 |
| 31 | SNTP设置 | 二级菜单 | 叶子 | 28 | 0 |
| 32 | 记录查询 | 一级菜单 | 菜单 | 0 | 9 |
| 33 | SOE记录 | 二级菜单 | 叶子 | 32 | 0 |
| 34 | 事故记录 | 二级菜单 | 叶子 | 32 | 0 |
| 35 | 操作记录 | 二级菜单 | 叶子 | 32 | 0 |
| 36 | 保护告警 | 二级菜单 | 叶子 | 32 | 0 |
| 37 | 保护启动 | 二级菜单 | 叶子 | 32 | 0 |
| 38 | 遥控记录 | 二级菜单 | 叶子 | 32 | 0 |
| 39 | 自检记录 | 二级菜单 | 叶子 | 32 | 0 |
| 40 | 运行记录 | 二级菜单 | 叶子 | 32 | 0 |
| 41 | 运行报告 | 二级菜单 | 叶子 | 32 | 0 |
---
## 三、数据层:ui_menu_tab(生成物,只读)
> **一句话职责**:提供"有什么条目"——一张扁平父子索引表(42 节点,`const` 存 Flash、只读)+ 4 个查询 API,供逻辑层 / 显示层共用。
> **绝不做**:持有任何状态、依赖 LVGL、被手改(改菜单走 §3.6 生成器)。
### 3.1 选型:扁平"父子索引表"(邻接表 / 关系表模型)
成熟树存储方案对比(参考 Wikipedia "Tree (abstract data type)"、文件系统/关系库的层级表示):
| 方案 | 原理 | 本方案取舍 |
|---|---|---|
| 指针树(动态节点,父/子指针) | 运行时动态增删 | ✗ 需堆 + 指针,嵌入端 GC/碎片/内存风险,`const` 入 Flash 困难 |
| 首子-兄弟(left-child right-sibling) | 每节点只存 first_child / next_sibling | 紧凑但"取某节点全部子项"需链式遍历,不如邻接直观,调试不友好 |
| 数组位置表示(堆式) | 靠下标位置推父子(2i/2i+1) | ✗ 要求满二叉/固定出度;菜单出度不定且有命名,不适用 |
| **邻接表 / 父子索引表(关系表)** | 每行自带 `parent` 索引,子项=parent 等于本下标的行 | ✓ **选用**:无指针、每行自描述、增删改=改行、可入 Flash、启动可校验 |
> LVGL 8 已移除 `lv_menu` 控件(`lv_conf.h` 中 `LV_USE_MENU 0`),本项目的扁平表方案与"无 lv_menu"约束一致,且比控件更贴合嵌入端。
**核心不变式**:
- 子项 = 表中所有 `parent == 本下标` 的行,**按表序**排列(表序即显示序)。
- **菜单 vs 叶子由"是否有子项"自动推导**:加子项→自动出现 ▶;删光子项→自动变叶子。无需单独的类型字段,替换时零维护。
### 3.2 数据结构定义
```c
#include <stdint.h>
#include <stdbool.h>
#define MENU_ROOT 0xFFu /* 根哨兵:仅作根节点(主界面 #0)的 parent,表示"它是顶层";8 位,上限 255 项 */
/* 菜单节点 = 扁平表一行;子项 = parent 等于本下标的所有行(按表序) */
typedef struct {
uint8_t parent; /* 父节点下标(0..254);仅根节点(主界面#0)为 MENU_ROOT(0xFF) */
const char *name; /* 显示文本(字面量,存 Flash) */
} ui_menu_node_t;
```
> 实现落在生成器产物 `Drivers/BSP/UI/ui_menu_tab.h`(见 §3.6);字段/语义与此一致。
### 3.3 完整 42 节点表(按 §2.3 节点定义表落地)
> 产物 `ui_menu_tab.c`(§3.6 生成):`const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT]`,内容与下表一致。
```c
const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {
/* parent name 下标 */
{ MENU_ROOT, "主界面" }, /* 0 主界面菜单(根) */
{ 0, "装置信息" }, /* 1 一级菜单叶子 */
{ 0, "实时数据" }, /* 2 一级菜单 */
{ 2, "交流量" }, /* 3 二级菜单叶子 */
{ 2, "直流量" }, /* 4 二级菜单叶子 */
{ 2, "遥信量" }, /* 5 二级菜单叶子 */
{ 0, "参数定值" }, /* 6 一级菜单 */
{ 6, "整定" }, /* 7 二级菜单 */
{ 7, "参数" }, /* 8 三级菜单叶子 */
{ 7, "定值" }, /* 9 三级菜单叶子 */
{ 7, "控制字" }, /* 10 三级菜单叶子 */
{ 7, "软压板" }, /* 11 三级菜单叶子 */
{ 6, "查看" }, /* 12 二级菜单 */
{ 12, "参数" }, /* 13 三级菜单叶子 */
{ 12, "定值" }, /* 14 三级菜单叶子 */
{ 12, "控制字" }, /* 15 三级菜单叶子 */
{ 12, "软压板" }, /* 16 三级菜单叶子 */
{ 0, "三遥设置" }, /* 17 一级菜单 */
{ 17, "遥测死区" }, /* 18 二级菜单叶子 */
{ 17, "遥测系数" }, /* 19 二级菜单叶子 */
{ 17, "遥信类型" }, /* 20 二级菜单叶子 */
{ 17, "遥信防抖" }, /* 21 二级菜单叶子 */
{ 17, "双点遥信" }, /* 22 二级菜单叶子 */
{ 0, "装置维护" }, /* 23 一级菜单 */
{ 23, "时钟设置" }, /* 24 二级菜单叶子 */
{ 23, "强制复归" }, /* 25 二级菜单叶子 */
{ 23, "手动录波" }, /* 26 二级菜单叶子 */
{ 23, "清除记录" }, /* 27 二级菜单叶子 */
{ 0, "通讯参数" }, /* 28 一级菜单 */
{ 28, "通讯设置" }, /* 29 二级菜单叶子 */
{ 28, "网口设置" }, /* 30 二级菜单叶子 */
{ 28, "SNTP设置" }, /* 31 二级菜单叶子 */
{ 0, "记录查询" }, /* 32 一级菜单 */
{ 32, "SOE记录" }, /* 33 二级菜单叶子 */
{ 32, "事故记录" }, /* 34 二级菜单叶子 */
{ 32, "操作记录" }, /* 35 二级菜单叶子 */
{ 32, "保护告警" }, /* 36 二级菜单叶子 */
{ 32, "保护启动" }, /* 37 二级菜单叶子 */
{ 32, "遥控记录" }, /* 38 二级菜单叶子 */
{ 32, "自检记录" }, /* 39 二级菜单叶子 */
{ 32, "运行记录" }, /* 40 二级菜单叶子 */
{ 32, "运行报告" }, /* 41 二级菜单叶子 */
};
```
> `UI_MENU_TAB_COUNT`(=42)与 `MENU_ROOT` 在 `ui_menu_tab.h` 定义。
### 3.4 查询 API(逻辑层 / 显示层共用)
4 个查询 API 统一模块前缀 `ui_menu_tab_*`(与逻辑层 `menu_*` 函数名区分),参数 `uint8_t`(菜单上限 255 项),随表生成在 `ui_menu_tab.c`:
```c
/* ui_menu_tab.h(生成物;逻辑层与显示层共用) */
uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max); /* 取 node 直接子项(按表序),最多 max 个;返回实际个数 */
bool ui_menu_tab_has_children(uint8_t node); /* 有子项=菜单(带▶);无=叶子 */
const char *ui_menu_tab_name(uint8_t node); /* 显示名 */
bool ui_menu_tab_valid(void); /* 启动自检:非根 parent 下标均有效 */
```
**各层用哪几个**(谁问数据层什么问题):
| API | 逻辑层(键分发) | 显示层(建框渲染) |
|---|---|---|
| `children` | ✓ 取子项/子项数(判空框、取选中子节点) | ✓ 取本框条目列表 |
| `has_children` | — | ✓ 判行尾有无 ▶ |
| `name` | — | ✓ 行文本 |
| `valid` | ✓ 初始化自检(§1.5 第 1 步) | — |
**建框 = 对"当前层节点"做一次过滤**(条目**现算不落地**,见 §7.3):
```c
/* 显示层 ui_menu_view_frame_build(depth, node, sel, scroll) 内: */
uint8_t kids[MENU_ITEM_MAX];
uint8_t cnt = ui_menu_tab_children(node, kids, MENU_ITEM_MAX); /* node = s_path[depth] */
for (uint8_t i = 0; i < cnt; i++) {
/* 注册一行:s_row_node[depth][i] = kids[i](框体自绘时现查名称/箭头) */
}
```
> 导航/显示一律按 `parent == 节点` 过滤,**改名/换序/增删子项均不影响导航逻辑**。
### 3.5 启动自检:`ui_menu_tab_valid()`
> 实现随表生成在 `ui_menu_tab.c`:校验**非根节点 parent 下标均 < `UI_MENU_TAB_COUNT`**(捕获表内笔误/生成器缺陷)。
> 由 `ui_menu_init()` 首行调用(§1.5 第 1 步);失败 → 打 `DBG_ERROR("menu table: INVALID…")` 并放弃建菜单(返回 -1)。
> 校验取 parent 界校验即够——生成器保证 parent 恒为"上级节点下标"(结构上无环),界校验足以兜底。
### 3.6 声明式生成器:`ui_menu_tree.txt` → `ui_menu_tab.c/.h`(与字体生成 `ui_cn_chars.txt → make_font.py` 同模式)
菜单层级声明在**缩进即层级**的文本源 `ui_menu_tree.txt`(仓库根,每级 2 空格、`#` 注释),
脚本读源、推导每个节点 `parent` 下标、产出 C 表。**改菜单只编辑文本源,重跑脚本即可**,导航/显示代码零改动。
| 文件 | 说明 |
|---|---|
| `ui_menu_tree.txt` | **人工编辑源**(缩进即层级);改菜单改这里 |
| `tools/menu_gen/gen_menu.py` | 生成器(同 `make_font.py` 模式):解析缩进→推导 parent→产出 C 表;含根唯一/缩进对齐/层级上限自检 |
| `Drivers/BSP/UI/ui_menu_tab.h` | 产物:`ui_menu_node_t` 定义 + `MENU_ROOT` + `UI_MENU_TAB_COUNT` + `extern g_menu_tab[]` |
| `Drivers/BSP/UI/ui_menu_tab.c` | 产物:`g_menu_tab[UI_MENU_TAB_COUNT]` 完整 42 节点表 |
重新生成:
```
python tools/menu_gen/gen_menu.py
```
> 产物 `ui_menu_tab.c/.h` 含 43 节点(9 菜单 + 34 叶子,最深 三级菜单;2026-10-29 录波道加两叶后由 42 升到 43);`ui_menu.c` 不持有本地表,导航/显示一律走 `ui_menu_tab_*` 查询 API(§3.4)。
> **编码陷阱(已踩)**:产物含中文**字符串字面量**,必须 **UTF-8 带 BOM**(同 `ui_menu.c`)。
> 无 BOM 时 cl.exe(代码页 936/GBK)会按 GBK 误读 UTF-8 字节 → 字符串解析错乱(C2001/C4819)。`gen_menu.py` 已用 `utf-8-sig` 输出。
### 3.7 改菜单流程
```text
1. 编辑 ui_menu_tree.txt —— 缩进即层级(每级 2 空格),# 为注释;增/删/改名/换序条目都只改这里;
行尾可选 ` #P` = 该叶子需密码(密码页按所输密码授权查看/修改权限,
密码值在 Drivers/BSP/UI/ui_pwd_def.h 编译期配置)
2. python tools/menu_gen/gen_menu.py —— 重跑生成器:表 / UI_MENU_TAB_COUNT / 8 位护栏 全部同步更新
3. 完成:导航(逻辑层)与渲染(显示层)代码零改动
```
> 注意:新增**中文汉字**时,须同步加入字体字符集 `ui_cn_chars.txt` 并重生成字体(见 `UI_中文字体生成与配置.md`),否则显示成方框。
### 3.8 Flash 占用(const,只读)
| 符号 | 定义处 | 内容 | 占用 |
|---|---|---|---|
| `ui_menu_node_t` | `ui_menu_tab.h` | `{ uint8_t parent; uint8_t pwd; const char *name; }`(32 位 8 B/项) | 类型定义 |
| `g_menu_tab[UI_MENU_TAB_COUNT]` | `ui_menu_tab.c`(生成) | 42 节点扁平父子表 | ≈ 42×8 B ≈ 336 B |
| 名字字面量 | 同上 | 42 个 `const char[]` | ≈ 400 B |
| `UI_MENU_TAB_COUNT` / `MENU_ROOT` | `ui_menu_tab.h` | 节点数 42 / 根哨兵 0xFF(8 位,上限 255 项) | 宏 |
> 逻辑层/显示层**只读**它(§3.4 的 API 都是对它的过滤/查询),从不修改。合计 ≈ **0.7 KB Flash**。
---
## 四、显示规范:横向级联子框模型
> **本章是显示层(§五)要实现的"图纸"**:讲"页"(每级的子项列表)在**屏上怎么摆**——每级一个**框**,贴靠在其**父框右侧**——一棵**横向的树**(往右生长)。
> 以下规则由参考图 02~07(`docs/UI设计/mockups/`)逐像素实测得出(设备 px,误差 ≤3px);参考图为**手工 mockup**,与实现的偏差见 §8.2 注 5。
### 4.1 总览
![横向级联子框总览(主界面 → 参数定值 → 整定)](../images/级联框总览.svg)
- 每级一个**框**,框 = 该级条目列表(紧凑列表,**无逐框页头/返回按钮**,返回靠 `←`);
- **无滑动动画**(1bpp 推流);
- 屏上几框 = 深度 × 各框宽 决定(不固定)。
### 4.2 水平布局(X):右贴靠 + 自适应宽
- 子框**左缘 = 父框右缘**:共享边框列、零间隙;逐级向右生长(实测:各截图子框均恰好贴在父框右缘 x≈67/106 处)。
- 框宽 = 该框最宽条目(文字 + ▶ + 边距 + 边框),按内容自适应(公式见 §5.3)。参考图实测:4字+箭头 ≈68、4字无箭头 ≈60、3字 ≈46、2字+箭头 ≈40;**实现实测**(模拟器像素统计):66 / 54 / 46 / 42,另有空终点框 6(偏差 2/8/0/2 px)。
- 最深框**不钉屏幕右缘**(实测:实时数据子框止于 x≈113/160;三遥/装置维护/记录查询子框止于 x≈127/160)。
- 本菜单最深路径宽 = 实现实测 66+42+42+6−3(共享边框)= **153 < 160** → 级联不会超屏宽;>160 参考图未覆盖,实际行为 = 框被全屏容器在屏缘裁切(右缘截断),无特殊处理。
### 4.3 垂直布局(Y):每框独立(顶对齐 + 底对齐上移)
- 默认:顶边 = 父框"选中行"**行顶**(实时数据/整定两例实测 Δ≤2px);框高 = 条目数 × 行高(**按内容**,不强顶屏底)。
- (顶边 + 框高) 超出屏底 → 整框上移使**子框底边对齐父"选中行"行底**(不是贴屏底):参考图实测 三遥设置 top=26 / 装置维护 top=54 / 通讯参数 top=82 / 记录查询 top=12;**实现实测 28 / 58 / 88 / 13**(Δ=2/4/6/1 px——四个样本不满足同一公式,实现取"底边对齐选中行行底"这一自洽规则,两端极例(三遥/记录查询)精确吻合,见 §8.2 注 5)。
- 上移后框可叠到屏顶状态行/装饰区(记录查询子框 top≈12)——**顶部不钳制**。
- 比整屏还高 → `↑`/`↓` 滚(初始对齐"选中行")。
### 4.4 选中态与焦点
- **选中态**:整行反色(白带、黑字);菜单行有 **▶**,叶子行无;**选中行保留 ▶**(箭头随反色变黑,参考图 02/04 实测);非选中行黑底白字(白箭头)。
- **焦点** = 最右(最深)框;框无页头/返回按钮(框即紧凑列表,返回靠 `←`);仅根框带主界面装饰(顶部"浅 V" + 状态行"当前模式 : 无模式")。
---
## 五、显示层:ui_menu_view(持有全部 LVGL 对象)
> **一句话职责**:按"状态 + 数据表"渲染级联框——持有**全部** LVGL 对象(样式 / 持久容器 / 框体;根框装饰与状态行由容器自绘、无对象),由逻辑层经 8 个接口(§5.5)驱动建/毁/刷新。行不是对象,由框体自绘。
> **绝不做**:持有导航状态(17 B 在逻辑层);解释按键(输入回调挂在逻辑层)。
### 5.1 职责边界
- 持有全部 LVGL 对象(样式/持久容器/框体);根框装饰与状态行由容器自绘(无对象)
- 按 `frame_build/destroy/refresh` 调用建/毁/刷新(依据"状态 + 数据表")
- 现算框几何(§5.3 公式)并按层缓存(`s_frame[]`)
- 提供滚动位读写(`set_scroll` / `get_scroll`)供逻辑层持久化
### 5.2 布局常量(设备 px,定义于 `ui_menu_view.c`)
| 常量 | 值 | 含义 |
|---|---|---|
| `MENU_ROW_H` | 15 | 行高(字体行高 13 + 上下各 1) |
| `MENU_ROW_TXT_TOP` | 1 | 行内文字垂直偏移(居中) |
| `MENU_ROW_PAD_L` / `MENU_ROW_PAD_R` | 2 / 2 | 行内左/右留白(紧凑,保证级联总宽 <160) |
| `MENU_ARROW_GAP` | 2 | 行内 文本→箭头 间距 |
| `MENU_FRAME_BORDER` | 1 | 框边框宽(子框与父框共享该边框列) |
| `MENU_ROOT_BOX_X` / `MENU_ROOT_BOX_Y` | 0 / 44 | 根框左上(左对齐、贴左缘) |
| `MENU_ROOT_STATUS_X` / `MENU_ROOT_STATUS_Y` | 17 / 20 | 状态行"当前模式 : 无模式" |
| `MENU_DECO_LEG_W` / `MENU_DECO_HORIZ_W` | 3 / 2 | 顶部"浅 V"腿线宽 / 水平线宽(1bpp 斜线边缘易被阈值吃掉,腿须比水平线粗) |
字体度量(`ui_font_cn_12`,宋体 12px 1bpp):汉字步进 12 px/字、▶ 步进 10 px(adv_w=165/16)、行高 13 px(见 `UI_中文字体生成与配置.md`)。
### 5.3 几何计算(`ui_menu_view_frame_build()`)
`frame_build` 拿到 (depth, node, sel, scroll) 后,先**算三个数**——框宽 w、位置 (x, y)、框高 h——再照数建框体、注册行数据、应用选中/滚动。四步各配一格图(下图 ①~④):
![级联框几何计算四步:① 行剖面定框宽 ② X 贴父框右缘 ③ Y 顶对齐选中行 ④ Y 溢出上移](../images/级联框几何计算.svg)
**① 框宽 w = 最宽行 + 2 条边框**(图①)
一行横向构成:`边框1 | 留白2 | 文本 |(菜单行: 间距2 + ▶10)| 留白2 | 边框1`。文本宽按字体实际步进量(`lv_text_get_width`,⚠️ 见本文 §8.2 第 1 条(`lv_text_get_width` 的 `length=0` 陷阱));同框各行宽度可以不同,**框宽只由最宽的一行决定**,窄行左对齐、右侧留空:
w = max_行( PAD_L + 文本_w + [GAP + ▶_w] + PAD_R ) + 2×BORDER
实测五类框宽(1× 原尺寸,px):
| 框 | 最宽行构成 | 框宽 |
|---|---|---|
| 根框(7 行,4 字 + ▶) | 2 + 48 + 2 + 10 + 2 | **66** |
| 4 字无箭头框 | 2 + 48 + 2 | 54 |
| 3 字无箭头框 | 2 + 36 + 2 | 46 |
| 2 字 + ▶ 框 | 2 + 24 + 2 + 10 + 2 | 42 |
| 空终点框(无行) | 仅留白 + 边框 | 6 |
**② 子框 X:贴在父框右缘,共享 1px 边框列**(图②)
x = 父.x + 父.w − 1
减掉的 1 = 父框右边框那列像素:子框的左边框与父框的右边框**是同一列**(边框重叠 1px),所以零间隙、不重画,逐级向右生长。
**③ 子框 Y(默认):顶边对齐父框"选中行"行顶**(图③)
y = 父.y + BORDER + 父sel × ROW_H
父框选中哪一行,子框顶边就落在那一行的**行顶**——父子垂直方向"肩并肩",选中行不动子框就不动。
**④ 子框 Y(溢出时):整框上移,底边对齐选中行行底**(图④)
y + h > 160 → y = 选中行行顶 + ROW_H − h
子框比"选中行以下到屏底"的空间高 → 整框上移,底边卡在**选中行行底**(不是贴屏底——贴屏底会让父子脱钩,选中行一移子框位置就乱跳)。上移后仍顶出屏顶(y<0)→ 钳到 0;再不够 → 框高截到可见区、行在框内滚动(§5.4 `LV_SCROLLBAR_MODE_AUTO`)。
**框高 h = 按内容,不强顶屏底**
h = 行数 × ROW_H + 2×BORDER
行数少框就矮(2 行 = 32 px)、行数多框就高;上限受屏高 160 约束(触发④的上移/截断)。
**公式汇总**(`ui_menu_view_frame_build()` 的实现即此五式):
```text
w = max_行(PAD_L + 文本_w + [菜单: GAP + ▶_w] + PAD_R) + 2×BORDER (文本宽经 lv_text_get_width)
h = 行数 × ROW_H + 2×BORDER
子框 x = 父.x + 父.w − 1 (d>0,共享边框列)
子框 y = 父.y + BORDER + 父sel×ROW_H (d>0,顶对齐选中行行顶)
y + h > 160 → y = 选中行行顶 + ROW_H − h;y < 0 → y = 0(截断/框内滚动兜底)
```
### 5.4 对象模型
- 屏幕根容器(全屏 flat)→ **单一持久全屏容器** `s_cont`(各层框均建其内;`LV_OBJ_FLAG_IGNORE_LAYOUT` + 绝对定位,透明,永不删;级联框须同屏叠加)
- 根框体 `s_frame_box[0]` 持久(位置 (0,44)、宽自适应);子框体进入时建 / 返回时毁(`lv_obj_create(s_cont)` + flat + 边框样式 + 自绘回调,`IGNORE_LAYOUT` 绝对定位)
- 框体:自动滚动条(`LV_SCROLLBAR_MODE_AUTO`);行自绘(无子对象),行内容超框高才框内滚动(内容高经 `LV_EVENT_GET_SELF_SIZE` 虚报)
- 行 = **数据**(节点下标注册 `s_row_node[d][i]`,无对象);框体 `LV_EVENT_DRAW_MAIN` 自绘:选中行 = 整行白带(`lv_draw_rect`),每行 = `lv_draw_label` 文本(左留白 2)+ 箭头(紧跟文本、间距 2;叶子无箭头),静态字符串(Flash 表、不拷贝);按滚动偏移枚举可见行、重绘区外的行跳过(culling)
- 选中行 = 整行反色(白带、黑字),**箭头随反色变黑**(§4.4);非选中行黑底白字(白箭头)
- 根框装饰(浅 V + 状态行):容器 `DRAW_MAIN` 自绘(3 段线 `lv_draw_line` + 1 行文本 `lv_draw_label`,无对象;两腿 3px、水平线 2px)
- 空终点框:叶子 ENTER 后建 w=6、h=2 的占位框(无行),仅 ESC 可返回
### 5.5 公开 API
| 接口 | 作用 | 调用方 |
|---|---|---|
| `int ui_menu_view_init(void)` | 样式 + 持久容器(装饰/状态行自绘回调)+ 根框框体(含自绘回调)(一次性) | 逻辑层 `ui_menu_init` |
| `lv_obj_t *ui_menu_view_container(void)` | 取持久容器(焦点/键唯一入口;逻辑层在其上绑输入组/键回调) | 逻辑层 |
| `void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t scroll)` | 建该深度框:现算几何 + 注册行数据 + 应用选中/滚动(d=0 仅(重)注册行) | 逻辑层(进 / 初始化) |
| `void ui_menu_view_frame_destroy(uint8_t depth)` | 毁该深度框(框体连自绘回调整体删除、行数据清空;容器保留;根框 d=0 不可毁) | 逻辑层(退) |
| `void ui_menu_view_frame_refresh(uint8_t depth, uint8_t sel)` | 刷选中(选中数据 + 跟随滚动 + 重绘框体,不重建框) | 逻辑层(移选后) |
| `void ui_menu_view_set_scroll(uint8_t depth, uint16_t scroll)` | 应用滚动偏移(行数据重注册后按此还原) | 逻辑层 |
| `uint16_t ui_menu_view_get_scroll(uint8_t depth)` | 读当前滚动偏移(供逻辑层回读持久化) | 逻辑层 |
| `void ui_menu_view_focus(void)` | 焦点置持久容器(唯一键入口;框深度由 `s_depth` 决定) | 逻辑层(进/退) |
> 公共常量:`MENU_PAGE_MAX 4`(深度上限 = 根框 + 3 级子框)、`MENU_ITEM_MAX 16`(单框最大条目数)、`MENU_SCREEN_W/H 160`(与 `ui_port.h` / 模拟器一致)。
### 5.6 RAM 字段(显示层)
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_cont` | `lv_obj_t*` | 4 B | 单一全屏持久容器(焦点/键唯一入口;各层框建其内);透明常驻、**永不删**(防焦点对象悬空) |
| `s_row_node[4][16]` | `uint8_t` | 64 B | 行数据:节点下标(名称/箭头绘制时现查,无对象);**仅屏上框注册** |
| `s_style_frame/scrollbar` | `lv_style_t`×2(动态)+ flat 常量样式(Flash) | ~200 B | 样式(一次性初始化,常驻;flat 为常量样式零池占用,见 §9.5) |
| `s_frame_box[4]` | `lv_obj_t*`×4 | 16 B | 框体(d=0 持久;d>0 进入时建、返回时毁) |
| `s_frame[4]` | `menu_frame_t`(built/sel/item_cnt/x/y/w/h) | 48 B | 每框几何缓存:子框对齐要用**父框刚算出**的 x/w/选中行,故按层留一份;随时可由 `s_path`/`s_sel` 重算,**不属于导航状态** |
> **只给"屏上的框"注册行数据**(每框 ≤16 行);返回时清掉被关闭最右框的行数据 → 行自绘无对象,LVGL 池只占**屏上框体**(2~3 框),关闭即释放。
---
## 六、逻辑层:ui_menu(纯状态机)
> **一句话职责**:持有**唯一** 17 B 导航状态(路径/选中/深度/滚动)+ 键语义(移/进/退),单向驱动显示层建/毁/刷新级联框。
> **绝不做**:持 LVGL 对象句柄(唯一例外:输入接线 2 调 `lv_group_add_obj`/`lv_obj_add_event_cb`);渲染。
### 6.1 职责边界
- 持有 17 B 导航状态(唯一持有者,§6.2)
- 解释按键:移(↑↓)/ 进(→/ENTER)/ 退(←/ESC)(§6.4)
- 查询数据层(`children`/`valid`)判键行为(§3.4)
- 单向驱动显示层:`frame_build/destroy/refresh/focus` + 滚动位回读持久化
### 6.2 17 B 导航状态(唯一"必须持久"的状态)
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_path[4]` | `uint8_t`×4 | 4 B | **路径**:`s_path[d]`=第 d 层"展示的节点下标"(`s_path[0]=0` 主界面);即"从根到当前(最右)框"的节点链 |
| `s_sel[4]` | `uint8_t`×4 | 4 B | **各层选中下标**:返回时还原该层高亮(选中不跳) |
| `s_depth` | `uint8_t` | 1 B | **当前深度**(0=根框)= 焦点所在(最右)框 |
| `s_scroll[4]` | `uint16_t`×4 | 8 B | **每框滚动偏移**(px):该框销毁再进入、行重建时按此还原位置 |
| **合计** | | **17 B** | 其余(条目列表/名字/菜单叶子/框几何)全部**现算**,不落 RAM(见 §7.3) |
### 6.3 导航规则:数据如何驱动"移 / 进 / 退"
> **一句话**:导航**从不遍历树**,它只反复向 `g_menu_tab` 问一个问题——
> **"谁的 parent 等于当前节点?"** 答案(按表序)就是当前页要显示的条目。
> **进** = 把选中的子项记为新的"当前节点"再问一遍;**退** = 忘掉它、还原上一个"当前节点"。
> 表是**扁平**的(无嵌套指针),"树"只是"反复按 parent 过滤"营造出来的错觉。
**当前页 = 对表的一次过滤**(页面**按需构建**——进入时建、返回时毁,内存 = 当前导航路径,见 §7.2):
```text
显示"节点 N 的页面" ⇔ 取 g_menu_tab 中所有 parent == N 的行(按表序 = 显示序)
```
| 你所在的页 | 当前节点 N | 执行的查询 | 页面上出现的条目 |
|---|---|---|---|
| 主界面(根页) | 0(主界面) | `parent == 0` | 装置信息、实时数据▶、参数定值▶、三遥设置▶、装置维护▶、通讯参数▶、记录查询▶(**7 项**)|
| 参数定值页 | 6(参数定值) | `parent == 6` | 整定▶、查看▶(**2 项**)|
| 整定页 | 7(整定) | `parent == 7` | 参数、定值、控制字、软压板(**4 项,全叶子**)|
> 行尾有没有 **▶** 也由同一张表判:`parent == 该节点` **非空** → 菜单(有 ▶);**为空** → 叶子(无 ▶)。
**移(↑ / ↓)**:只改当前页的选中下标 `s_sel[d]`(**首尾环绕**),**不碰表**。
**进(ENTER / →)**:选中条目 = 子节点 **M**(`kids[s_sel[d]]`)。向表问一句"**M 还有孩子吗?**"(`parent == M` 非空?)
- **有** → M 是菜单:**压一层**,`s_path[d+1]=M`,再按"过滤"显示 M 的孩子。
- **没有** → M 是叶子:进**终点**(空终点框占位,本阶段不展开内容页),不再下钻。
**退(ESC / ←)**:**弹一层**——销毁最右框、回到 depth-1 的父框;父框选中项**保持**为"刚才那个 M 在父框里的下标"(= 按下 ENTER 前的位置,不跳)。
**完整走查**(`#` = `g_menu_tab` 下标):主界面 → 参数定值 → 整定 → 参数,再一级级退回
```text
【根页 node=0】 parent==0 → 7 项;选中 [2]=参数定值(#6)
ENTER ─▶ 问"6 有孩子吗"→ parent==6 非空(整定/查看) → 压层
【参数定值页 node=6】 parent==6 → [整定#7, 查看#12];选中 [0]=整定
ENTER ─▶ 问"7 有孩子吗"→ parent==7 非空(参数/定值/控制字/软压板) → 压层
【整定页 node=7】 parent==7 → [参数#8, 定值#9, 控制字#10, 软压板#11];选中 [0]=参数
ENTER ─▶ 问"8 有孩子吗"→ parent==8 为空 → 叶子 → 进终点(空框)
【参数终点 node=8】(占位,无条目,到此为止)
ESC ─▶ 弹层 → 回【整定页】,选中仍 [0]=参数
ESC ─▶ 弹层 → 回【参数定值页】,选中仍 [0]=整定
ESC ─▶ 弹层 → 回【根页】,选中仍 [2]=参数定值
```
**导航状态 = 一条"当前节点"链**(= 从根到当前框的路径)
```text
s_path[0]=0 ─▶ s_path[1]=6 ─▶ s_path[2]=7 ─▶ s_path[3]=8
根页 参数定值页 整定页 参数终点
depth=0 depth=1 depth=2 depth=3
```
- 链上**每个 node 都是上一个的孩子**(parent 关系)→ 这条链天然就是路径,**无需额外记录"我从哪来"**。
- **进 = 链尾追加**(选中的子节点接上去);**退 = 链尾弹掉**。
- 内存只占"当前这条链"(`s_path[0..s_depth]`)+ 屏上各框各一套行数据(屏外框行清空),退回即释放(§7.2/§7.5)。
**对应到代码**(逻辑层决定、显示层执行;三层见 §一):
| 逻辑 | 函数 | 读 / 写的数据 |
|---|---|---|
| 建框(显示某节点子项) | 显示层 `ui_menu_view_frame_build(d, node, sel, scroll)`(由逻辑层调用) | 读 `s_path[d]`+`g_menu_tab` 现算条目;算 x/顶边/框宽、注册 `s_row_node[d][*]`、恢复 `s_scroll[d]` |
| 进(压层 / 终点) | 逻辑层 `menu_open(child)` | 写 `s_depth+1`、`s_path[d+1]=M`、`s_sel[d+1]`(同节点还原 / 换节点清零);再 `frame_build` + `focus` |
| 退(弹层) | 逻辑层 `menu_back()` | `ui_menu_view_frame_destroy(s_depth)`;`s_depth-1`、焦点回父框(父框不动、无需重建,`s_sel`/`s_scroll` 原样保留) |
| 移选 | 逻辑层 `menu_key_handle(d, key)` → `menu_refresh_layer(d)` | 写 `s_sel[d]`(首尾环绕);`frame_refresh`(高亮 + 跟随滚动);滚动位回读入 `s_scroll[d]` |
### 6.4 键语义与分发
**键位语义**:
| 键 | 行为 |
|---|---|
| `↑` / `↓` | 当前(最右)框内上/下移动选中(**首尾环绕**);到边缘且该框可滚则**滚动该框** |
| `→` 或 `ENTER` | 选中**菜单节点** → 其右侧**弹出子框**;选中**叶子节点** → 进终点(空框) |
| `←` 或 `ESC` | 返回:**关最右框**(毁框体+行数据)、焦点回父框、选中态保持(不跳) |
| 焦点 / 框头 | 焦点 = 最右(最深)框;**无逐框页头/返回按钮**(框即紧凑列表,返回靠 `←`);仅根框带主界面装饰 |
**进入/返回示例**(路径,`→`/`←` = 进/退动作方向):
```
主界面 → 参数定值 → 整定 → 参数
主界面一级菜单 二级菜单三级菜单
返回:主界面 ← 参数定值 ← 整定 ← 参数
```
**分发**(`menu_key_handle(d, key)`,`d` = 焦点所在层):
```text
cnt = ui_menu_tab_children(s_path[d], …) ← 先问数据层:本框几个条目
UP/DOWN : cnt>0 → s_sel[d] 首尾环绕 → menu_refresh_layer(高亮+跟随滚动+滚动位回读)
ENTER/RIGHT: cnt>0 且 d+1<4 → menu_open(kids[s_sel[d]]) (叶子也压层 → 空终点框)
ESC/LEFT : d>0 → menu_back() (根框 d=0 不响应;空框只响应返回)
```
### 6.5 公开 API
- `int ui_menu_init(void)` —— **唯一公开入口**(uiTask 调用);五步时序见 §1.5,表自检见 §3.5。
- 内部(`static`):`menu_open` / `menu_back` / `menu_key_handle` / `menu_key_cb` / `menu_refresh_layer`(见 §6.3/§6.4)。
---
## 七、层间配合细节
### 7.1 按键全序列(逐键分层动作)
> 通用管线见 §1.4;本表按键列出**逻辑层改什么状态、显示层做什么**。
| 键 | 逻辑层(17 B 状态) | 显示层(画面) |
|---|---|---|
| **移** ↑/↓ | `s_sel[d] = (s_sel[d] ∓ 1 + cnt) % cnt`(首尾环绕);`cnt=0`(空框)不变 | `frame_refresh(d, s_sel[d])`:改选中数据 + 跟随滚动 + 重绘框体(不重建);随后逻辑层 `get_scroll(d)` 回读入 `s_scroll[d]` |
| **进** →/ENTER | `s_depth+1`;`s_path[d+1]=kids[s_sel[d]]`;`s_sel[d+1]`(同节点还原原选中/滚动,换节点清零) | `frame_build(d+1, …)`:现算几何(贴父框右缘、顶对齐/底对齐上移)→ 注册行数据 → 应用选中/滚动;`focus()`(焦点恒在持久容器);叶子 → 空终点框(w=6, h=2) |
| **退** ←/ESC | `s_depth−1`(根框 d=0 不响应) | `frame_destroy(d)`:毁框体(含自绘回调,容器保留,池内存即回落);`focus()`(焦点恒在持久容器);父框不动、无需重建,`s_sel`/`s_scroll` 原样保留 |
### 7.2 框生命周期(进建退毁)
- **进时建**(`frame_build`):现算几何(§5.3;父框几何取父层 `s_frame` 缓存)→ 注册行数据(行 = 子项,现算不落地)→ 应用选中高亮 + 滚动位。
- **退时毁**(`frame_destroy`):框体(含自绘回调)整体销毁、容器保留(永不删,防焦点对象悬空);LVGL 池内存随即回落。
- **根框持久**(`ui_menu_view_init` 建一次,永不毁);子框随进/退建/毁。
- **焦点**:持久容器为唯一焦点对象(初始化时入输入组并获焦,永不删、焦点不迁移);框深度由逻辑层 `s_depth` 决定,无焦点争抢。
- **空终点框**:叶子 ENTER 后建 w=6、h=2 占位框(无行),仅 ESC 可返回。
### 7.3 存 vs 算(关键区分)
> **核心原则:能推导的就不存**——某页的条目列表可由 `g_menu_tab` 的 `parent==节点` 随时重算;
> 故**只持久化"不可推导"的导航历史(路径 + 各层选中 + 各框滚动)**,页面内容进出时按需重建。
| 数据 | 存 / 算 | 依据 |
|---|---|---|
| 路径(在哪些层) | **存** `s_path`(逻辑层) | 用户导航历史,不可推导 |
| 各层选中 | **存** `s_sel`(逻辑层) | 用户选择,不可推导 |
| 每框滚动位 | **存** `s_scroll`(逻辑层) | 用户 `↑`/`↓` 滚动,不可推导 |
| 某框条目列表 | **算** | `ui_menu_tab_children(s_path[d])` = 过滤 `parent==s_path[d]` |
| 条目是菜单/叶子(有无 ▶) | **算** | `ui_menu_tab_has_children(node)` = 存不存在 `parent==node` |
| 条目显示名 | **算** | `g_menu_tab[node].name` |
| 每框 x / 顶边 / 框宽 | **算** | 右贴靠布局 + 顶对齐/底对齐上移(§四),现算(按层缓存在 `s_frame[]`) |
> **结论:持久导航状态 = `s_path` + `s_sel` + `s_depth` + `s_scroll`(共 17 B,全在逻辑层)**;
> 框的条目/名字/菜单叶子/位置(框宽·x·顶边) 100% 现算,进/退时重建。
### 7.4 导航操作 × 字段读写
| 操作 | 读 | 写 |
|---|---|---|
| **建框**(进 / 初始化) | `s_path[d]`、`g_menu_tab`(现算条目)、`s_sel[d]`、`s_scroll[d]` | 算该框 x/顶边/框宽(§四);注册 `s_row_node[d][*]`;恢复滚动位 |
| **移**(↑/↓) | `s_sel[d]`、`s_scroll[d]`、`s_row_node[d][*]` | `s_sel[d]`(首尾环绕);到边缘则改 `s_scroll[d]`(滚);刷新高亮 |
| **进**(ENTER/→,菜单) | `s_sel[d]`→子节点 M、`ui_menu_tab_has_children(M)`=真 | `s_depth+1`、`s_path[d+1]=M`、`s_sel[d+1]=0`;新框贴父框右缘生成(既有框不动、不重排) |
| **进**(ENTER/→,叶子) | `ui_menu_tab_has_children(M)`=假 | `s_depth+1`、`s_path[d+1]=M`(终点框) |
| **退**(ESC/←) | `s_depth` | `s_depth-1`、销毁最右框行、焦点回父框(父框不动、无需重建,`s_sel[d-1]`/`s_scroll[d-1]` 原样保留) |
### 7.5 内存预算
**Flash(const,数据层)**:`g_menu_tab` 336 B + 名字 ~400 B ≈ **~0.7 KB**
**RAM(静态/常驻)**:
| 组 | 层 | 大小 |
|---|---|---|
| 导航历史 `s_path`+`s_sel`+`s_depth` | 逻辑层 | 9 B |
| 每框滚动 `s_scroll[4]` | 逻辑层 | 8 B |
| 容器 `s_cont` | 显示层 | 4 B |
| 行数据 `s_row_node[d][i]` `[4][16]` | 显示层 | 64 B |
| 框体句柄 `s_frame_box[4]` | 显示层 | 16 B |
| 框几何缓存 `s_frame[4]`(`menu_frame_t`) | 显示层 | 48 B |
| 样式 `s_style_frame/scrollbar`(动态)+ flat 常量样式(Flash,零池占用 §9.5) | 显示层 | ~200 B |
| **小计** | | **~0.35 KB**(逻辑层 17 B + 显示层 ~0.33 KB) |
**LVGL 池(动态,`LV_MEM` 16 KB 预算内)**:行自绘无对象,池只占**屏上框体**(2~3 个 `lv_obj`)+ 根框装饰/状态行,
典型 ≈ **1~2 KB**,仍远小于 16 KB(池已于 2026-10-15 由 40 KB 缩到 16 KB,见 `docs/内存管理/RAM使用分析与优化方案.md` §4.1.1;真机开机实测 `menu build done: used=3192`)。
---
## 八、验证与实施陷阱
### 8.1 分级验证
| 环节 | 方法 |
|---|---|
| 模拟器无头 | `lvgl_sim.exe --screenshot shot.bin` / `--keys <seq> shot.bin`;I1 原始像素(160×160 = 3200 B)转 PNG(PIL)目检 |
| 对照参考图 | 7 状态(根页 + 6 个一级子框)逐一对照参考图 01~07;反色行/框几何用像素统计核验(偏差见 §8.2 注 5) |
| 编译 | `@build`(ARMCC,0 错 0 警,强制) |
| 真机 | `@test`(Required 含 `UI port init: OK;UI menu init: OK`;屏幕内容仍需人工目检) |
### 8.2 陷阱与保真度说明
1. 本 LVGL v9.3 的 `lv_text_get_width(txt, length, font, letter_space)` **length=0 时返回 0**(不是"整串");必须传 `strlen`——否则框宽计算全 0(根框退化 8 px)。
2. 无头模式**不得**调 `lv_timer_handler()` / `lv_refr_now()`:I1 + `LV_DISPLAY_RENDER_MODE_FULL` 全屏刷新会进渲染死循环(截图因此走 `lv_snapshot`);`--keys` 注键是同步处理,无需驱动帧。
3. 选中行**保留 ▶**(随反色变黑):参考图 02/04 原图即"选中行箭头仍在、仅随反色变黑",勿实现成"选中隐箭头"(§4.4)。
4. 字体缺字形 = 方框:菜单树新增/改名的汉字必须同步加入字符集 `ui_cn_chars.txt`(当前 224 唯一字,2026-10-29 实测)并重生成字体(重跑 `extract_cn.py` + `make_font.py`),否则新字显示为方框(流程见 `UI_中文字体生成与配置.md`)。
5. **参考图保真度**:参考图为手工 mockup,4 个样本(子框顶位置)不满足单一公式;实现采用自洽规则(顶对齐/底对齐上移),实测与参考图偏差:顶位置 2/4/6/1 px(三遥/装置维护/通讯参数/记录查询),框宽 2/8/0/2 px(4字+箭头/4字/3字/2字+箭头)。两端极例(三遥、记录查询)精确吻合;统一规则优于逐屏特调,偏差范围内不影响识读与操作。
6. 装饰"浅 V"两腿线宽必须 3(水平线 2):1bpp 下细斜线边缘被阈值吃掉,腿不粗于水平线即消失;且依赖 `LV_DRAW_SW_COMPLEX=1`(见 `UI_设计架构.md` §1.4 约束 7)。
---
## 九、内容页(ui_content_page)
> **一句话职责**:菜单叶子节点进入后的**独立全屏页面**——装置信息(node 1)、交流量(node 3)、直流量(node 4)、用户密钥密码页(受保护叶子,表单/操作页)。与级联框同屏互斥,按需建毁。
> **文件**:`Drivers/BSP/UI/ui_content_page.c/h`(MCU / PC 模拟器双端共用,注册见 `UI_设计架构.md` §1.2/§1.4 约束 5)。
### 9.1 对象树与生命周期
```text
菜单屏(持久) 内容页屏幕(按需建毁)
screen_menu screen_page = lv_obj_create(NULL)
└─ cont 持久容器(焦点/键入口) └─ s_cont 全屏容器(flat + CLICK_FOCUSABLE,唯一键入口)
├─ 根框体 / 子框体… (行全部自绘,无子对象——与级联框同一"行=数据"策略)
```
| 动作 | 序列 |
|---|---|
| **开页**(菜单 ENTER 叶子 node 1/3/4) | `lv_obj_create(NULL)` 新建屏 → 建全屏容器(flat 常量样式 + 键回调 + 自绘回调)→ 入默认输入组并获焦(先于切屏,按键立即由本模块接管)→ `lv_screen_load` 切屏。菜单屏对象**保留不销毁** |
| **关页**(ESC/←) | 焦点先回菜单持久容器 → 切回菜单屏(原样还原)→ 删容器、再删屏。LVGL 池内存随即回落 |
- **导航状态不压层**:菜单 `s_depth/s_sel/s_scroll` 原样保留,返回即还原;内容页只记自身选中行 `s_sel`(每次进入从首行开始,不跨次保留)
- **单页互斥**:同一时刻至多一个内容页打开(`s_open` 哨兵防御)
### 9.2 页面定义表(Flash 常量,几何实测自参考图 10/20/21)
| 参数 | 装置信息 | 交流量 | 直流量 |
|---|---|---|---|
| `selectable` | 0(静态键值行) | 1(数据列表) | 1(数据列表) |
| 行数 / 每页 | 6 / 不分页 | 24 / 8(3 页) | 3 / 单页 |
| 首行槽顶 y / 行距 | 40 / 15 | 24 / 14 | 24 / 14 |
| 选中白带高 | 无 | 13(x=2..156) | 13(x=2..156) |
| 左列 x / 值列 x | 标签 6 / 86 | 名 7 / 101 | 名 7 / 101 |
| 副标题 | "馈线自动化终端" y=25 | — | — |
| 页脚(y=143,x=6) | 固定"第 10 页,共 1 页"(按参考图原样) | 动态"第 X 页,共 3 页" | 动态"第 1 页,共 1 页" |
- 标题 y=2(居中,浅 V 装饰区内;装饰几何与菜单根框一致:两腿 3px / 水平线 2px)
- **用户密钥页**(表单/操作页,无行槽):title_y=44、"浅 V"/页脚关(hide_deco/hide_footer)、key_intercept=1;自绘 3 个 14×14 数字框(间距 6px、行顶 y=78、首框左 x=53)+ 反显光标,详见 `内容页架构.md` §4.1
- 列表页窗口 = 选中行所在页(首可见行 = `(sel/每页数)×每页数`),行槽位置固定、数据随页切换
- 数据表为 Flash `const cp_row_t[]`(名+值字面量,不 strdup);交流量第 2/3 页当前为占位数据
### 9.3 键语义
| 键 | 行为 |
|---|---|
| ↑ / ↓ | 列表页移选(**首尾环绕、跨页连续**),窗口随页切换后重绘;信息页不响应 |
| ESC | 关页返回菜单(导航状态原样还原;恒不可拦截) |
| ← | `key_intercept=1` 时先交页面 key_cb(true = 已处理不关页,如密码页移光标);未置标志或未处理 → 照旧关页返回 |
### 9.4 公开 API
```c
bool ui_content_page_open(content_page_id_t id); /* 开页:建屏+切屏+焦点置页面容器;ID 无效/已有页打开 → false */
void ui_content_page_close(void); /* 关页:焦点回菜单容器 + 切回菜单屏 + 销毁页面 */
void ui_content_page_invalidate(void); /* 整页重绘(表单/操作页自身状态变化后调用) */
bool ui_content_page_is_open(void); /* 查询是否有内容页打开 */
```
- 节点→页映射在逻辑层 `ui_menu.c`(`menu_content_page_for(node)`):node 1 → `CONTENT_PAGE_DEVICE_INFO`、3 → `AC`、4 → `DC`;其余叶子无内容页(进空白终点框)。需密码叶子(行尾 ` #P`,如 node 8 参数;密码检查先于内容页判断,带内容页的需密码叶子同样先经密码页)先 arm + 开 `CONTENT_PAGE_PASSWORD`,确认一致后回调(携授予权限) **先关页再续做进入动作**(有内容页→开内容页携权限,页面经 `ui_content_page_is_editable()` 判只读/可编辑);无密码叶子直接开(默认只读)
### 9.5 内存预算(模拟器 x64 实测,16 KB 池)
| 时刻 | LVGL 池 used | 说明 |
|---|---|---|
| 菜单基线(根框建完) | 5368 B | 同 §1.5;2026-10-11 裁剪 LVGL 控件类后基线 5440→5368,本行旧值 4784 是更早的快照 |
| 内容页打开峰值 | +776~1192 B(增量口径) | 增量为实测值、与基线无关:**绝对水位随基线漂移,5560~5976 B 是老基线 4784 时代的快照,按 5368 基线的绝对峰值待重测**(回归脚本只钉基线与往返两个数) |
| 关页后回落 | **5448 B** | 一次性残留 **+80 B**(见下),非泄漏 |
**+80 B 一次性残留分解**(均为 LVGL 核心行为,与内容页模块无关;MCU 32 位下更小):
- +8 B:`disp->screens` 数组高水位——开页时第 5 个屏使 TLSF 块 32→40 B,关页缩回后块不归还(分配器行为)
- +72 B:`lv_screen_load()` 内部对菜单屏执行 `lv_obj_set_pos(act_scr,0,0)` → 局部 X/Y 样式各分配一个零初始化 `lv_style_t`+属性数组;每屏一次性,随菜单屏销毁才释放
**常驻样式零池占用**:flat 样式用常量样式(`LV_STYLE_CONST_INIT` + `lv_style_const_prop_t[]`,属性存 Flash)。动态 `lv_style_t` 首次 set_prop 会从池分配 `values_and_props` 且永不释放(x64 实测 88 B/个)——常驻样式禁用动态初始化(见 `UI_设计架构.md` §5.6)。