UI 菜单初步完成

This commit is contained in:
2026-09-07 10:48:18 +08:00
parent 89462d8aa1
commit 2b41bdccb5
30 changed files with 2226 additions and 859 deletions
@@ -27,6 +27,14 @@
安装完成后验证可用:`lv_font_conv -h` 能打印用法即成功。
> **本机 Python 解释器**:PATH 默认 `python` 是 msys64 的(无 pip/fonttools),直接跑 `make_font.py` 会失败。用 conda base 环境(已预装 fontTools):
>
> ```powershell
> & 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py
> ```
>
> `extract_cn.py` 纯标准库,任意 python3 均可。换机器时若该解释器缺 fonttools,用其自带包管理器安装(如 `& '...\python.exe' -m pip install fonttools`)。
---
## 3. 提取字符集(只取会显示的汉字)
@@ -42,11 +50,11 @@ python tools/font_gen/extract_cn.py
输出示例:
```
scanned 9 files; unique non-ASCII chars: 83
scanned 13 files; unique non-ASCII chars: 78
-> D:\Code\DTU Code\STM32F4-Base\ui_cn_chars.txt
```
产物 `ui_cn_chars.txt` 为本工程实际显示的汉字清单(当前 83 字),可留作字符集记录。
产物 `ui_cn_chars.txt` 为本工程实际显示的汉字清单(当前 78 字),可留作字符集记录。
---
@@ -63,12 +71,12 @@ pip install fonttools # 仅当使用 .ttc 集合字体(如宋体)时需
> 若 `npm i -g lv_font_conv` 报代理错误,先清代理:`npm config delete proxy` / `npm config delete https-proxy`。
**执行命令(在仓库根目录):**
**执行命令(在仓库根目录,用 §2.1 的 conda base 解释器):**
```bash
python tools/font_gen/make_font.py # 默认:宋体, 12px, 1bpp
python tools/font_gen/make_font.py --bpp 2 # 宋体细笔画发虚时改用 2bpp
python tools/font_gen/make_font.py --font C:/path/to/font.ttf # 换其他字体
```powershell
& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py # 默认:宋体, 12px, 1bpp
& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py --bpp 2 # 宋体细笔画发虚时改用 2bpp
& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py --font C:/path/to/font.ttf # 换其他字体
```
输出示例:
@@ -76,8 +84,8 @@ python tools/font_gen/make_font.py --font C:/path/to/font.ttf # 换其他字
```
extracted SimSun regular (face0) -> C:\Users\...\Temp\simsun_regular.ttf
RUN: lv_font_conv.cmd --font ... --size 12 --bpp 1 -r 0x20-0x7E --symbols ... --no-compress --format lvgl --lv-font-name ui_font_cn_12 -o ...\ui_font_cn_12.c
已合并箭头 U+25B6:位图+10 字节,glyph_id=181
OK 36393 bytes -> ...\Drivers\BSP\UI\ui_font_cn_12.c
已合并箭头 U+25B6:位图+10 字节,glyph_id=173
OK 35485 bytes -> ...\Drivers\BSP\UI\ui_font_cn_12.c
```
> **▶ 箭头自动合并**:宋体(`simsun.ttc`)没有 U+25B6(▶)字形,`lv_font_conv` 会静默跳过 → 真机显示成方框。`make_font.py` 在生成后自动调用 `merge_arrow_glyph()`,把从 **Segoe UI Symbol**(`seguisym.ttf`,Windows 自带)预提取的箭头字形(位图 10 字节 + `glyph_dsc` + `cmap`)补回 `ui_font_cn_12.c`。该合并**幂等**(已存在则跳过),所以重生成字体后箭头不会丢。换用自带 ▶ 的字体(如黑体/Segoe)时可忽略此步。
@@ -138,7 +146,7 @@ OK 36393 bytes -> ...\Drivers\BSP\UI\ui_font_cn_12.c
1. **1bpp 细笔画发虚**:宋体在 1bpp 下可能丢像素。若上板/客户端中文偏淡,把第 4 节 `--bpp` 改为 `2` 重生成即可(子集小,体积可接受)。
2. **字符集变更**:以后新增中文菜单项,须重跑第 3、4 节(重新提取 `ui_cn_chars.txt` 并生成),否则新字显示为方框。
3. **编码**:UI 源文件须为 **UTF-8(带 BOM)**,LVGL 按 UTF-8 解码中文串;字体 `.c` 为纯 ASCII,无编码风险。当前 Keil 已加 Misc Controls 抑制项,无需额外 `--locale=english`。
4. **字体体积**:12px + 1bpp + ~83 字 ≈ 36KB(RO-data);改 bpp 2 约翻倍,仍在 F407 Flash 余量内。
4. **字体体积**:12px + 1bpp + ~78 字 ≈ 35.5KB(RO-data);改 bpp 2 约翻倍,仍在 F407 Flash 余量内。
5. **换字体**:黑体用 `simhei.ttf`(单文件免抽取);其余字体同理替换 `--font` 路径后重生成。
---
+47 -23
View File
@@ -2,33 +2,41 @@
> **用途**:规范"参考图 → HTML 样机 → LVGL 代码 → 双端验证"的完整管线,规定每一步的**动作、产物、放行条件(gate)**,保证过程可重复、可交接。
> **适用**:所有 UI 新界面 / 界面大改;小改动走 §11 快速通道。
> **配套文档**:`UI_设计架构.md`(系统如何工作)、`LVGL模拟器部署与使用说明.md`(模拟器)、`UI_中文字体生成与配置.md`(字体生成)。
> **配套文档**:`UI_设计架构.md`(系统如何工作)、`菜单页面设计.md`(菜单/导航行为与数据流设计)、`LVGL模拟器部署与使用说明.md`(模拟器)、`UI_中文字体生成与配置.md`(字体生成)。
> **首个实例**:界面 01 主界面(根页),样机 `mockups/01_主界面.html`(本文所有示例数字均取自该试点)。
---
## 一、流程总览
**五阶段单向管线**:`参考图 → P0 → P1 → P2 → P3 → P4 → P5`。每阶段产出明确**产物**、满足**放行条件(Gate)**才进入下一阶段;其中 **P1、P3 是"比对 → 修正"迭代环**,未收敛不得前进。
```text
参考图
│
▼
P0 需求录入 ────────────▶ 界面台账 + 问题清单
│ Gate: 界面定位/极性已确认 + 阻塞问题有答案
▼
P1 图片→HTML 样机 ──────▶ mockups/NN.html + NN_说明.md
│ ⟲ 迭代: 渲染样机↔原图比对→修正;用户浏览器确认↔改 HTML
│ Gate: 样机定稿 + 原图校验通过 + 问题有答案 + 字符集核对完成
▼
P2 HTML→LVGL 代码 ──────▶ Drivers/BSP/UI 代码 + 字体(重生成)
│ Gate: 参数逐项一致 + 字体已重生成 + 内存预算 + 双端注册
▼
P3 模拟器验证 ──────────▶ shot.png ↔ 样机渲染图 同构
│ ⟲ 迭代: 出图↔样机渲染图比对 → 依 HTML 改码 → 再出图
│ Gate: 出图与样机渲染图同构 + 导航符合键位语义
▼
P4 MCU 验证 ────────────▶ 日志 + 真机目检结论
│ Gate: @build 0错0警 + @test PASS + 真机目检通过
▼
P5 归档 ────────────────▶ 变更记录/台账 + 一并提交
(代码 + 字体 + 字符集 + 样机 + 文档 进 git)
```
参考图 ──▶ [P0 需求录入] ──▶ 界面台账 + 问题清单
│ gate: 界面定位/极性/阻塞问题已确认
▼
[P1 图片→HTML样机] ──▶ mockups/NN_界面名.html
│ ▲ │
│ └── 用户浏览器确认(循环至定稿)
│ gate: 样机定稿 + 字符集核对完成
▼
[P2 HTML→LVGL代码] ──▶ Drivers/BSP/UI/ 代码 + 字体重生成
│ gate: 代码与样机参数逐项一致 + 字体已重生成 + 内存预算通过
▼
[P3 模拟器验证] ──▶ 出图 → 与样机渲染图比对 → 不合依 HTML 改码
│ gate: 模拟器出图与样机渲染图同构(忽略抗锯齿/字形微差)
▼
[P4 MCU 验证] ──▶ @build + @test + 真机目检
│ gate: 0错0警 + test PASS + 目检通过
▼
[P5 归档] ──▶ 变更记录/台账更新
```
> 小改动(文案/间距/纯交互/字体重生成)走 §11 快速通道,无需全流程。
### 1.1 各阶段职责分工
@@ -216,11 +224,11 @@
### 5.4 字体重生成(固定顺序,不得跳过或调换)
```
1. 代码落库(新界面字符串已写入 ui_menu.c 等 UI 源码)
2. python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 → 仓库根 ui_cn_chars.txt(自动含新字)
3. python tools/font_gen/make_font.py # 宋体 12px 1bpp + ASCII + 字符集 → ui_font_cn_12.c(全量重生成 + 自动补回宋体缺失的 ▶ 箭头,幂等)
2. python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 → 仓库根 ui_cn_chars.txt(自动含新字;纯标准库,任意 python3)
3. & 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py # 宋体 12px 1bpp + ASCII + 字符集 → ui_font_cn_12.c(全量重生成 + 自动补回宋体缺失的 ▶ 箭头,幂等;需 fontTools)
4. 生成物 diff 检查:新增字在位、旧字不变、Opts 行字符集与预期一致
```
- 一次性环境准备:`npm i -g lv_font_conv`、`pip install fonttools`
- 一次性环境准备:`npm i -g lv_font_conv`;fonttools 用 conda base 解释器装(本机 `C:\ProgramData\anaconda3\python.exe` 已预装 fontTools,PATH 默认 python 是 msys64 的、无 pip/fonttools,勿用它跑 make_font.py)
- `ui_cn_chars.txt`(仓库根,受 git 跟踪)与 `ui_font_cn_12.c` **随代码一起提交**
- 双端 `LV_FONT_DEFAULT` 均指向 `&ui_font_cn_12` → 重生成后两端自动生效
- P1 已知的新增字若含生僻字(宋体可能无此字形)→ 在写码前先手动验证字形,避免"代码写完才发现是空白"
@@ -309,6 +317,20 @@
3. **每批收尾**:字体统一重生成(各屏字符集**并集**,一次生成)→ 全量回归(已完成界面重跑 @test)
4. 字符集只增不减;字体文件每次**全量重生成**,禁止手工拼合
### 9.1 样机投入分流(按屏型定 HTML 投入度)
> **两条正交轴**:HTML 样机只解决“**长什么样**”(静态外观);“**怎么动 / 数据从哪来**”(行为 / 导航 / 数据驱动)靠设计文档(`菜单页面设计.md`)+ LVGL 模拟器(动态 oracle)。两轴互补——HTML 是“设计先行”的确定性工具,**不是代码生成器**,别指望它加速行为代码。
按屏型决定 P1 样机投入(收益从高到低):
| 屏型 | 例 | P1 样机投入 | 行为 / 数据靠什么定 |
|---|---|---|---|
| 静态、元素多的内容页 | 数据显示 / 参数定值 / 装置信息 | **完整** HTML + `_说明.md`(收益最大:几何钉死→抄写 + 缺字/1bpp 排雷前移 + P3 客观视觉基准) | 数据绑定 + 少量交互 → 设计文档 |
| 导航 / 交互重的屏 | 菜单(横向级联框)/ 参数编辑 | HTML 只做**静态首帧**样子;交互重头放**设计文档** | 设计文档定状态机/级联框/键位语义 + 模拟器验证行为 |
- **HTML 的真实价值**(确定但偏窄,集中在 P1/P3):① 几何从“推导”降为“抄写” ② 缺字 / 1bpp 禁区在 P1 排雷(比 P4 真机发现便宜得多) ③ P3 提供客观视觉基准(`render_NN.png`) ④ 用户浏览器廉价确认。
- **HTML 帮不上**(本工程 C 的复杂度大头,与 HTML 无关):LVGL 管线(离屏渲染 / 输入组 / 焦点 / 容器持久)、导航状态机、数据驱动(`g_menu_tab` 一表驱动)、运行时内存水位。这些靠 LVGL 功底 + `UI_设计架构.md` + 模拟器。
---
## 十、常见坑速查
@@ -419,3 +441,5 @@ P5 [ ] UI_设计架构.md 变更记录更新
| 2026-09-03 | 字体 ▶(U+25B6)箭头:宋体无此字形 → 从 Segoe UI Symbol 提取位图合并入 `ui_font_cn_12.c`;`make_font.py` 内置 `merge_arrow_glyph()` 幂等自动补回,重生成字体后箭头不丢 |
| 2026-09-03 | 顶部"开口浅 V"装饰两腿不渲染:根因 `simulator/lv_conf.h` 设 `LV_DRAW_SW_COMPLEX 0`(省代码)→ `lv_line` 斜段被跳过。改为两端显式 `LV_DRAW_SW_COMPLEX 1` + `LV_DRAW_SW_CIRCLE_CACHE_SIZE 4`;三图(原图/HTML/截图)顶部装饰现已一致 |
| 2026-09-03 | 顶部"开口浅 V"两腿偏细(1bpp 斜线边缘被阈值吃掉):`ui_menu.c` 装饰拆 3 段 `lv_line`,两腿加粗 `MENU_DECO_LEG_W`=3、水平线 `MENU_DECO_HORIZ_W`=2(`deco_line_apply` 统一样式),三图腿粗现已一致(约 2~3px) |
| 2026-09-03 | §一 流程总览重画为清晰的纵向管线图:每阶段标注 产物 + Gate,P1/P3 的"比对→修正"迭代环显式标出(⟲);补全原先遗漏的 P1 原图校验环/`NN_说明.md` 产物、P2 双端注册、P3 导航 Gate,与各阶段细则(§3~§8)对齐 |
| 2026-09-04 | §九 新增 9.1“样机投入分流(按屏型)”:明确 HTML 样机=静态外观轴(设计先行、非代码生成器),设计文档 + 模拟器=行为/数据轴;静态内容页走完整样机管线、导航/交互重屏 HTML 只做静态首帧、行为靠 `菜单页面设计.md`;配套文档补列 `菜单页面设计.md` |
+58 -43
View File
@@ -16,13 +16,16 @@ MCU **无物理屏幕**:LVGL 做 160×160 **1bpp 单色离屏渲染**,帧缓
| 文件 | 端 | 职责 |
|---|---|---|
| `Drivers/BSP/UI/ui_port.c/h` | MCU | LVGL 离屏显示端口(I1 + flush 到内存帧缓冲)+ 远端 keypad 输入 |
| `Drivers/BSP/UI/ui_menu.c/h` | **共用** | 基元控件页面导航菜单(`g_menu_tab[]` 节点表驱动) |
| `Drivers/BSP/UI/ui_menu.c/h` | **共用** | 菜单导航逻辑层:持有导航状态(路径/选中/深度/滚动,共 17 B)+ 键语义,单向驱动显示层;不持 LVGL 对象 |
| `Drivers/BSP/UI/ui_menu_view.c/h` | **共用** | 菜单显示层:横向级联框渲染(右贴靠/自适应宽/顶底对齐),持有全部 LVGL 对象与样式、无导航状态 |
| `Drivers/BSP/UI/ui_menu_tab.c/h` | **共用** | 菜单数据层:`g_menu_tab[]` 父子索引表 + 查询 API(生成物,勿手改) |
| `Drivers/BSP/UI/ui_net_sink.c/h` | MCU | RemoDispBus 服务端(收键 + 回送帧缓冲) |
| `Drivers/BSP/UI/ui_font_cn_12.c` | **共用** | 中文 1bpp 子集字体(`lv_font_conv` 生成物) |
| `App/task/ui_task.c/h` | MCU | UI 任务:等 NET_READY → 初始化 → 10ms 刷新循环 |
| `Lib/lv_conf.h` | MCU | LVGL 配置(1bpp / 40KB 内存池 / 默认字体) |
| `simulator/`(main.c / sim_port.c / lv_conf.h / CMakeLists.txt) | PC | SDL2 模拟器(替代 ui_port,共享 ui_menu.c 与字体) |
| `tools/font_gen/`(extract_cn.py / make_font.py) | 工具 | 字符集提取 / 字体生成 |
| `simulator/`(main.c / sim_port.c / lv_conf.h / CMakeLists.txt) | PC | SDL2 模拟器(替代 ui_port,共用全部 UI 源码);支持 `--screenshot` / `--keys <seq>` 无头截图验证 |
| `tools/font_gen/`(extract_cn.py / make_font.py)+ `ui_cn_chars.txt` | 工具 | 字符集提取 / 字体生成 |
| `tools/menu_gen/gen_menu.py` + `ui_menu_tree.txt` | 工具 | 菜单声明源 → 生成 ui_menu_tab.c/h |
### 1.3 关键参数
@@ -42,7 +45,7 @@ MCU **无物理屏幕**:LVGL 做 160×160 **1bpp 单色离屏渲染**,帧缓
2. **新增汉字必须重新生成字体**(`tools/font_gen/` 两步),否则显示空白
3. 文本一律用静态字符串(`lv_label_set_text_static`),不 strdup
4. UI 相关文件为 **UTF-8 带 BOM**(`Drivers/BSP/UI/*`、`ui_task.c`、`Lib/lv_conf.h`),其余源码无 BOM;禁止 PowerShell 整文件重写(编码铁律,见 AGENTS.md)
5. 模拟器与 MCU **共用** `ui_menu.c` / `ui_font_cn_12.c`,禁止在 `simulator/` 下复制副本
5. 模拟器与 MCU **共用** UI 源码(`ui_menu*.c` / `ui_font_cn_12.c`),禁止在 `simulator/` 下复制副本;新增文件须同时注册进 Keil `.uvprojx` 与模拟器 CMakeLists(双端注册)
6. 验证顺序:模拟器预览 → `@build`(0 错 0 警)→ `@test`(Required 加 `UI port init: OK;UI menu init: OK`)
7. **`LV_DRAW_SW_COMPLEX` 必须为 1**(两端均显式声明):主界面顶部"开口浅 V"装饰含斜线,`lv_line` 的斜段仅在复杂渲染器下绘制(`draw_line_skew`),关闭则只画水平线、两腿消失。且 `LV_DRAW_SW_CIRCLE_CACHE_SIZE` 须非零(默认 4),否则零长数组编译报错(C2466)
@@ -84,12 +87,12 @@ LVGL 场景(160×160,LV_COLOR_FORMAT_I1 单色)
按键 → keypad 输入设备(LV_INDEV_TYPE_KEYPAD)
→ 默认输入组(lv_group)
→ 组内焦点对象 = 当前可见页面容器
→ 容器的 LV_EVENT_KEY 回调(menu_page_key_cb)
→ 容器的 LV_EVENT_KEY 回调(menu_key_cb,user_data = 该层深度)
```
- keypad 输入设备经 `lv_group_send_data` 把键码发给**焦点对象**
- keypad 输入设备经 `lv_group_send_data` 把键码发给**焦点对象**(模拟器 `--keys` 无头验证走同一路径)
- LVGL v9 的输入组**不自动注册默认键回调**(方向键移动焦点需焦点对象自己实现)
→ 页面容器是组内唯一焦点对象,按键全部由容器统一处理,无焦点争抢
→ 4 个深度容器都在组内,但同一时刻仅最右框容器获焦,按键一律由逻辑层统一处理,无焦点争抢
- 按下时输入设备捕获焦点对象(indev_obj_act),释放时向其回发 RELEASED/CLICKED
→ 焦点对象(容器)必须存活,这是"容器持久不删"的原因之一(见 §5.2)
@@ -116,59 +119,70 @@ SDL 键盘 → LVGL SDL 驱动内部 5ms 定时器(sdl_event_handler)
| 键 | 行为 |
|---|---|
| ↑ / ↓ | 移动选中(首尾环绕);选中行反色高亮 + 自动滚动到可见区 |
| → / Enter | 打开选中条目的子页(叶子节点打开空白占位页) |
| ← / Esc | 返回上级页面(本页条目销毁,父页选中态不变) |
| → / Enter | 选中条目右侧弹出子框(叶子节点建空白终点框) |
| ← / Esc | 关最右框(其行销毁,焦点回父框,选中态不变) |
## 四、菜单架构(核心)
### 4.1 数据驱动:节点表
> 显示模型细节(右贴靠/自适应宽/顶底对齐的像素规则与实测值)见 `菜单页面设计.md` §四⑧;本节只讲代码架构。
- `g_menu_tab[]`(`ui_menu.c`):62 个节点,每项 = 层级(0=根)+ 名称
- 树序即表序:某节点的子节点 = 其后**连续的同(level+1)层级段**
- 根页 = 全部一级节点(⚠️ 双根 `主界面`/`厂家设置` 混排在同一根页,已知问题,待拆分)
### 4.1 三层拆分(高内聚低耦合)
### 4.2 对象树
| 层 | 文件 | 职责 | 持 LVGL 对象? |
|---|---|---|---|
| 数据层 | `ui_menu_tab.c/h`(生成物) | `g_menu_tab[]` 父子索引表 + 查询 API(`children/has_children/name/valid`) | 否 |
| 显示层 | `ui_menu_view.c/h` | 级联框渲染:样式 / 4 层容器 / 框体 / 行 / 根框装饰;几何现算按层缓存 | 是(全部) |
| 逻辑层 | `ui_menu.c/h` | 导航状态(`s_path/s_sel/s_depth/s_scroll`,共 17 B)+ 键语义;单向驱动显示层建/毁/刷新 | 否 |
- 依赖方向:逻辑 → 显示,两者都用数据查询 API;显示层无导航状态、逻辑层无 LVGL 对象
- 改菜单只编辑 `ui_menu_tree.txt` 后重跑 `python tools/menu_gen/gen_menu.py`(再生成 ui_menu_tab.c/h)
### 4.2 数据:父子索引表
- `g_menu_tab[]`:42 节点,每项 = 名称 + parent 下标(`MENU_ROOT`=0xFF 为根);树序即表序
- "页" = 对表的一次过滤:节点 N 的页条目 = 全部 `parent == N` 的行(按表序)
- 菜单 vs 叶子由"是否有子项"自动推导:`has_children(N)` 非空 → 菜单行(带 ▶),为空 → 叶子(无箭头,ENTER 进空白终点框)
### 4.3 对象树
```
screen
└─ menu(铺满屏幕,flex 纵向)
├─ header(高 18px:返回按钮 28×16 + 标题 label)
└─ content(flex grow,占满剩余高度)
├─ page[0] 根页容器(depth 0)
├─ page[1] 一级子页容器(depth 1)
├─ page[2] 二级子页容器(depth 2)
└─ page[3] 三级子页容器(depth 3)
└─ menu(铺满屏幕,flat)
├─ cont[0] 全屏容器(depth 0):根框装饰"浅 V" + 状态行 + 根框体(持久)
├─ cont[1] 全屏容器(depth 1):子框体进入时建、返回时毁
├─ cont[2] 全屏容器(depth 2)
└─ cont[3] 全屏容器(depth 3)
```
- 4 个页面容器**持久存在**(每深度一个),永不删除;同一时刻仅一个可见(HIDDEN 切换)
- 每个容器:flex 纵向排列条目 + 自动滚动条 + 加入默认输入组 + 容器级 `LV_EVENT_KEY` 回调
- 页头返回按钮:真 `lv_button`,**不入输入组**(不参与键导航),点击回调预留供将来触摸输入;根页隐藏
- 4 层容器**持久存在**永不删(焦点/键入口,见 §5.2);子对象全部绝对定位(级联框须同屏叠加,层间不用 flex 堆叠)
- 框体 = `lv_obj`(透明底 + 1px 白边框 + 纵向 flex 堆行 + 自动滚动条),行建在框体内
### 4.3 条目行
### 4.4 条目行与选中态
- 行 = `lv_label`(整行一个 label:宽 100%、高随内容、文本左对齐)
- 行**不入输入组**:纯展示,按键一律由容器处理
- 选中行 = CHECKED 状态 → 反色(白底黑字,1bpp 下即"高亮")
- 行高 ≈ 字体 12px + 上下内边距 2×2 ≈ 16px;根页 12 行 ≈ 192px > 页面区 142px → 滚动条出现
- 行 = `lv_obj` 容器(整行宽、高 15px,承载整行反色高亮)+ 文本 label(左留白 2px)+ 箭头 label(紧跟文本、间距 2px;叶子无箭头)
- 行**不入输入组**:纯展示,按键一律由容器 → 逻辑层处理
- 选中行 = CHECKED 状态 → 整行反色(白底黑字),**箭头随反色变黑**(参考图 02/04 实测:选中行保留 ▶);非选中行透明底白字
### 4.4 页面生命周期(按需构建)
### 4.5 框生命周期(按需建毁)
```
ENTER/→ 打开子页:
下一深度容器上构建条目(label 行)→ 隐藏旧容器、显示新容器 → 焦点移至新容器
ENTER/→ 进子框:
现算几何(x = 父框右缘 − 1 共享边框列;y = 父"选中行"行顶,超屏底则上移使本框底对齐该行行底)
→ cont[depth+1] 建框体 + 行 → 焦点移至新容器
←/Esc 返回:
销毁本页条目(lv_obj_clean,容器保留)→ 显示父容器 → 焦点移回父容器
销毁最右框(框体连行整体删除,容器保留)→ 焦点回父容器;父层选中/滚动位原样保留
```
- **容器持久、条目动态**:LVGL 池内存占用 = 当前路径(根页 12 行 + 各级子页,最坏约 25KB < 40KB 池)
- 叶子节点(无子节点):打开空白占位页(0 条目),Enter 无动作,Esc 返回
- **容器持久、框动态**:池内存 = 屏上各框行数之和(实测根页约 13KB,随深度增加但受"屏上框数 × 行数"有界,远低于 40KB 池)
- 同节点再次进入:还原该框上次选中与滚动位(`s_sel/s_scroll`);换节点则清零
- 叶子节点(无子项):ENTER 建空白终点框(w=6、h=2 占位),仅 ESC 可返回
- 深度保护:depth+1 ≥ 4 不再下钻
### 4.5 焦点与按键处理
### 4.6 焦点与按键处理
- 焦点对象 = 当前可见页面容器;切页时 `lv_group_focus_obj` 显式转移
- 容器回调 `menu_page_key_cb` 统一分发(见 §3.4 键位语义)
- 选中变化只改行的 CHECKED 状态 + `lv_obj_scroll_to_view` 跟随,不重建对象
- 焦点对象 = 最右(最深)框的容器;进/退时 `lv_group_focus_obj` 显式转移
- 每层容器挂 `menu_key_cb`(user_data = depth),统一分发到逻辑层 `menu_key_handle`:↑↓ 移选(首尾环绕)+ 跟随滚动、→/ENTER 进、←/ESC 退
- 选中变化只改行状态 + `lv_obj_scroll_to_view` 跟随,不重建对象;滚动位回读持久化到 `s_scroll[d]`
## 五、设计决策与考量(为什么这样做)
@@ -185,11 +199,11 @@ ENTER/→ 打开子页:
- 若在按键处理器内删除焦点对象(如"返回时销毁当前页"),释放事件将落在已释放内存上 → use-after-free
- 容器永不删除,回发事件始终落在存活对象上,天然安全;要释放的只是容器内的条目
### 5.3 条目按需建/销
### 5.3 框按需建/销
- LVGL 池仅 40KB(`LV_MEM_SIZE`),全量预建 62 个节点的所有页面必然爆内存
- 只建"当前路径"上的页面:内存 = 根页 + 各级子页条目(最坏约 25KB)
- 返回即 `lv_obj_clean` 释放,内存随时回落到根页水位
- LVGL 池仅 40KB(`LV_MEM_SIZE`),全量预建 42 个节点的所有页面必然爆内存
- 只建"当前路径"上的框:内存 = 屏上各框行数之和(实测根页约 13KB,最坏级联远低于池上限)
- 返回即 `lv_obj_delete` 释放,内存随时回落到根框水位
### 5.4 容器是唯一焦点对象
@@ -226,3 +240,4 @@ ENTER/→ 打开子页:
| 2026-09-01 | 代码规范钩子落地,UI 代码随全库规范修复 |
| 2026-09-02 | 菜单重构:弃用 lv_menu → 基元控件页面导航(§4/§5);字体 83→78 字(补「返/回」);修复模拟器主循环吞事件致键盘失效(§5.7);修复选中行白底白字(§5.5) |
| 2026-09-03 | 关键约束 7:`LV_DRAW_SW_COMPLEX` 须=1(顶部"浅 V"斜线依赖);两端 lv_conf 显式固化,`LV_DRAW_SW_CIRCLE_CACHE_SIZE`=4 防零长数组 |
| 2026-09-04 | 菜单重构为"横向级联框"模型(见 `菜单页面设计.md` §四⑧):数据/显示/逻辑三层拆分(新增 `ui_menu_view` 显示层、`ui_menu_tab` 生成物,`ui_menu` 瘦身为纯状态机);字体重生成补缺字形(78 字,含菜单树新增的整/控/软/板等);模拟器加 `--keys <seq>` 无头注键截图验证 |
@@ -40,7 +40,7 @@
→ **确定新增 8 个字形**:三 当 前 模 式 无 : ▶。
写 LVGL 代码前必须重新生成字体,否则新增字在真机显示为空白。流程:代码落库 → `python tools/font_gen/extract_cn.py`(自动从 UI 源码字符串提取)→ `python tools/font_gen/make_font.py` → 两端(MCU/模拟器)同时生效。
写 LVGL 代码前必须重新生成字体,否则新增字在真机显示为空白。流程:代码落库 → `python tools/font_gen/extract_cn.py`(自动从 UI 源码字符串提取)→ `& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py`(需 fontTools,见 `UI_中文字体生成与配置.md` §2.1)→ 两端(MCU/模拟器)同时生效。
## 四、待确认问题
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+699
View File
@@ -0,0 +1,699 @@
# 菜单页面设计(逻辑层级定义)
> **用途**:定义 DTU 主菜单的**完整逻辑层级树与导航逻辑**。
> **范围**:本阶段**只做菜单层级**(节点、父子、层级、进出导航);各叶子进入后的**内容页**(字段/交互/数据)另行设计,本文不含。
> **来源**:用户提供的主菜单截图 7 屏(根页 + 实时数据/参数定值/三遥设置/装置维护/通讯参数/记录查询各一级子页 + 参数定值·整定二级)。
> **导航模型**:树形**逐级进入**——`ENTER`/`→` 进入子页,`ESC`/`←` 返回上一级;最多 **4 级(L0~L3)**。
---
## 一、层级总览
主菜单为 4 级树:
| 层级 | 含义 | 例子 |
|---|---|---|
| **L0 根** | 主界面,7 个一级条目 | 主界面 |
| **L1 一级** | 根页的 7 个条目 | 装置信息、实时数据、参数定值、三遥设置、装置维护、通讯参数、记录查询 |
| **L2 二级** | 一级子菜单的子项 | 交流量、整定、遥测死区、时钟设置、通讯设置、SOE记录 … |
| **L3 三级** | 仅"参数定值 → 整定/查看"下的子项 | 参数、定值、控制字、软压板 |
节点分两类(决定导航行为):
| 类型 | 标识 | 行为 |
|---|---|---|
| **菜单节点** | 行尾 **▶** 箭头 | `ENTER` 进入 → 展开下一级子页 |
| **叶子节点** | 无箭头 | `ENTER` 进入 → **终点**(本阶段仅占位,内容页后续设计) |
> 根页默认选中**第 1 项"装置信息"**(整行反色白底黑字)。
---
## 二、完整层级树
```text
主界面 (L0)
├─ 装置信息 [叶子]
├─ 实时数据 ▶ [菜单]
│ ├─ 交流量 [叶子]
│ ├─ 直流量 [叶子]
│ └─ 遥信量 [叶子]
├─ 参数定值 ▶ [菜单]
│ ├─ 整定 ▶ [菜单]
│ │ ├─ 参数 [叶子]
│ │ ├─ 定值 [叶子]
│ │ ├─ 控制字 [叶子]
│ │ └─ 软压板 [叶子]
│ └─ 查看 ▶ [菜单]
│ ├─ 参数 [叶子]
│ ├─ 定值 [叶子]
│ ├─ 控制字 [叶子]
│ └─ 软压板 [叶子]
├─ 三遥设置 ▶ [菜单]
│ ├─ 遥测死区 [叶子]
│ ├─ 遥测系数 [叶子]
│ ├─ 遥信类型 [叶子]
│ ├─ 遥信防抖 [叶子]
│ └─ 双点遥信 [叶子]
├─ 装置维护 ▶ [菜单]
│ ├─ 时钟设置 [叶子]
│ ├─ 强制复归 [叶子]
│ ├─ 手动录波 [叶子]
│ └─ 清除记录 [叶子]
├─ 通讯参数 ▶ [菜单]
│ ├─ 通讯设置 [叶子]
│ ├─ 网口设置 [叶子]
│ └─ SNTP设置 [叶子]
└─ 记录查询 ▶ [菜单]
├─ SOE记录 [叶子]
├─ 事故记录 [叶子]
├─ 操作记录 [叶子]
├─ 保护告警 [叶子]
├─ 保护启动 [叶子]
├─ 遥控记录 [叶子]
├─ 自检记录 [叶子]
├─ 运行记录 [叶子]
└─ 运行报告 [叶子]
```
**统计**:菜单节点 **9** 个(含根),叶子节点 **33** 个,共 **42** 个节点;最大深度 **4 级**。
---
## 三、节点定义表(逻辑层级定义)
> 扁平表,可直接映射 `g_menu_tab[]`:`#` = 数组下标,`父节点` = 父菜单的 `#`,`子项数` = 直接子项个数(叶子为 0)。
| # | 名称 | 层级 | 类型 | 父节点 | 子项数 |
|---|---|---|---|---|---|
| 0 | 主界面 | L0 | 菜单(根) | — | 7 |
| 1 | 装置信息 | L1 | 叶子 | 0 | 0 |
| 2 | 实时数据 | L1 | 菜单 | 0 | 3 |
| 3 | 交流量 | L2 | 叶子 | 2 | 0 |
| 4 | 直流量 | L2 | 叶子 | 2 | 0 |
| 5 | 遥信量 | L2 | 叶子 | 2 | 0 |
| 6 | 参数定值 | L1 | 菜单 | 0 | 2 |
| 7 | 整定 | L2 | 菜单 | 6 | 4 |
| 8 | 参数 | L3 | 叶子 | 7 | 0 |
| 9 | 定值 | L3 | 叶子 | 7 | 0 |
| 10 | 控制字 | L3 | 叶子 | 7 | 0 |
| 11 | 软压板 | L3 | 叶子 | 7 | 0 |
| 12 | 查看 | L2 | 菜单 | 6 | 4 |
| 13 | 参数 | L3 | 叶子 | 12 | 0 |
| 14 | 定值 | L3 | 叶子 | 12 | 0 |
| 15 | 控制字 | L3 | 叶子 | 12 | 0 |
| 16 | 软压板 | L3 | 叶子 | 12 | 0 |
| 17 | 三遥设置 | L1 | 菜单 | 0 | 5 |
| 18 | 遥测死区 | L2 | 叶子 | 17 | 0 |
| 19 | 遥测系数 | L2 | 叶子 | 17 | 0 |
| 20 | 遥信类型 | L2 | 叶子 | 17 | 0 |
| 21 | 遥信防抖 | L2 | 叶子 | 17 | 0 |
| 22 | 双点遥信 | L2 | 叶子 | 17 | 0 |
| 23 | 装置维护 | L1 | 菜单 | 0 | 4 |
| 24 | 时钟设置 | L2 | 叶子 | 23 | 0 |
| 25 | 强制复归 | L2 | 叶子 | 23 | 0 |
| 26 | 手动录波 | L2 | 叶子 | 23 | 0 |
| 27 | 清除记录 | L2 | 叶子 | 23 | 0 |
| 28 | 通讯参数 | L1 | 菜单 | 0 | 3 |
| 29 | 通讯设置 | L2 | 叶子 | 28 | 0 |
| 30 | 网口设置 | L2 | 叶子 | 28 | 0 |
| 31 | SNTP设置 | L2 | 叶子 | 28 | 0 |
| 32 | 记录查询 | L1 | 菜单 | 0 | 9 |
| 33 | SOE记录 | L2 | 叶子 | 32 | 0 |
| 34 | 事故记录 | L2 | 叶子 | 32 | 0 |
| 35 | 操作记录 | L2 | 叶子 | 32 | 0 |
| 36 | 保护告警 | L2 | 叶子 | 32 | 0 |
| 37 | 保护启动 | L2 | 叶子 | 32 | 0 |
| 38 | 遥控记录 | L2 | 叶子 | 32 | 0 |
| 39 | 自检记录 | L2 | 叶子 | 32 | 0 |
| 40 | 运行记录 | L2 | 叶子 | 32 | 0 |
| 41 | 运行报告 | L2 | 叶子 | 32 | 0 |
---
## 四、导航逻辑
**页面模型**:每个"菜单节点"对应一个子页(其直接子项的列表页);"叶子节点"进入后为终点页(本阶段占位)。页面**按需构建**——进入时建、返回时毁,内存 = 当前导航路径。
**当前页 = 某菜单节点的直接子项列表**:
- L0 根页:7 个一级条目(#1、#2、#6、#17、#23、#28、#32)
- L1 子页:如"实时数据"页 = {交流量, 直流量, 遥信量}
- L2 子页:如"整定"页 = {参数, 定值, 控制字, 软压板}
**键位语义**:
| 键 | 行为 |
|---|---|
| `↑` / `↓` | 当前(最右)框内上/下移动选中(**首尾环绕**);到边缘且该框可滚则**滚动该框** |
| `→` 或 `ENTER` | 选中**菜单节点** → 其右侧**弹出子框**;选中**叶子节点** → 进入终点页 |
| `←` 或 `ESC` | 返回:**关最右框**(销毁其行)、焦点回父框、选中态保持(不跳) |
| 焦点 / 框头 | 焦点 = 最右(最深)框;**无逐框页头/返回按钮**(框即紧凑列表,返回靠 `←`);仅根框带主界面装饰 |
**选中态**:整行反色(白底黑字);菜单行有 **▶**,叶子行无。
**进入/返回示例**(路径):
```
主界面 → 参数定值 → 整定 → 参数
L0 L1 L2 L3
返回:主界面 ← 参数定值 ← 整定 ← 参数
```
### 导航走查:`g_menu_tab` 到底怎么驱动"移 / 进 / 退"
> **一句话**:导航**从不遍历树**,它只反复向 `g_menu_tab` 问一个问题——
> **"谁的 parent 等于当前节点?"** 答案(按表序)就是当前页要显示的条目。
> **进** = 把选中的子项记为新的"当前节点"再问一遍;**退** = 忘掉它、还原上一个"当前节点"。
> 表是**扁平**的(无嵌套指针),"树"只是"反复按 parent 过滤"营造出来的错觉。
>
> **注**:①~⑦ 讲"**数据怎么驱动**"(每级 = 对 `parent` 的一次过滤,此处称"页");这些"页"在**屏上如何摆**(横向级联子框、子框贴父框右缘、顶对齐/底对齐上移/滚动)见 **⑧ 显示模型**。
**① 当前页 = 对表的一次过滤**
```text
显示"节点 N 的页面" ⇔ 取 g_menu_tab 中所有 parent == N 的行(按表序 = 显示序)
```
| 你所在的页 | 当前节点 N | 执行的查询 | 页面上出现的条目 |
|---|---|---|---|
| 主界面(根页) | 0(主界面) | `parent == 0` | 装置信息、实时数据▶、参数定值▶、三遥设置▶、装置维护▶、通讯参数▶、记录查询▶(**7 项**)|
| 参数定值页 | 6(参数定值) | `parent == 6` | 整定▶、查看▶(**2 项**)|
| 整定页 | 7(整定) | `parent == 7` | 参数、定值、控制字、软压板(**4 项,全叶子**)|
> 行尾有没有 **▶** 也由同一张表判:`parent == 该节点` **非空** → 菜单(有 ▶);**为空** → 叶子(无 ▶)。
**② 移(↑ / ↓)**:只改当前页的选中下标 `sel`(**首尾环绕**),**不碰表**。
**③ 进(ENTER / →)**:选中条目 = `item_node[sel]`,记作子节点 **M**。向表问一句"**M 还有孩子吗?**"(`parent == M` 非空?)
- **有** → M 是菜单:**压一页**,新页 `node = 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]`,共 9 B 历史)+ **屏上各框各一套行对象**(屏外框行销毁),退回即释放 → 即"框按需建毁、内存 = 路径"(详见 §七、⑧)。
**⑦ 对应到代码**(逻辑层 `ui_menu.c` 驱动显示层 `ui_menu_view.c`;三层拆分见 §八)
| 逻辑 | 函数(实现名) | 读 / 写的数据(见 §七)|
|---|---|---|
| ① 建框(显示某节点子项)| 显示层 `ui_menu_view_frame_build(d, node, sel, scroll)`(由逻辑层调用) | 读 `s_path[d]`+`g_menu_tab` 现算条目;算 x/顶边/框宽、建 `s_row[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]` |
**⑧ 显示模型:横向级联子框(右贴靠 + 自适应宽 + 无动画)**〔已按参考图 02~07 像素实测校准〕
> ①~⑦ 讲"**数据怎么驱动**"(每级 = 对 `parent` 的一次过滤);本节讲这些"页"在**屏上怎么摆**:每级一个**框**,贴靠在其**父框右侧**——一棵**横向的树**(往右生长)。
> 以下规则由参考图 02~07(`mockups/`)逐像素实测得出(设备 px,误差 ≤3px),非推测。
**水平(X):右贴靠 + 自适应宽**
- 子框**左缘 = 父框右缘**:共享边框列、零间隙;逐级向右生长(实测:各截图子框均恰好贴在父框右缘 x≈67/106 处)。
- 框宽 = 该框最宽条目(文字 + ▶ + 边距 + 边框),按内容自适应(公式见 §8.3)。参考图实测:4字+箭头 ≈68、4字无箭头 ≈60、3字 ≈46、2字+箭头 ≈40;**实现实测**(模拟器像素统计):66 / 54 / 46 / 42,另有空终点框 6(偏差 2/8/0/2 px,参考图为手工 mockup,见 §8.8 注 5)。
- 最深框**不钉屏幕右缘**(实测:实时数据子框止于 x≈113/160;三遥/装置维护/记录查询子框止于 x≈127/160)。
- 本菜单最深路径宽 = 实现实测 66+42+42+6−3(共享边框)= **153 < 160** → 级联不会超屏宽;>160 参考图未覆盖,实际行为 = 框被全屏容器在屏缘裁切(右缘截断),无特殊处理。
- 屏上几框 = 深度 × 各框宽 决定(不固定);**无滑动动画**(1bpp 推流,动画太费带宽)。
**垂直(Y):每框独立(顶对齐 + 底对齐上移)**
- 默认:顶边 = 父框"选中行"**行顶**(实时数据/整定两例实测 Δ≤2px);框高 = 条目数 × 行高(**按内容**,不强顶屏底)。
- (顶边 + 框高) 超出屏底 → 整框上移使**子框底边对齐父"选中行"行底**(不是贴屏底):参考图实测 三遥设置 top=26 / 装置维护 top=54 / 通讯参数 top=82 / 记录查询 top=12;**实现实测 28 / 58 / 88 / 13**(Δ=2/4/6/1 px——参考图为手工 mockup,四个样本不满足同一公式,实现取"底边对齐选中行行底"这一自洽规则,两端极例(三遥/记录查询)精确吻合,见 §8.8 注 5)。
- 上移后框可叠到屏顶状态行/装饰区(记录查询子框 top≈12)——**顶部不钳制**。
- 比整屏还高 → `↑`/`↓` 滚(初始对齐"选中行")。
**焦点 / 键**:焦点 = 最右框;`↑`/`↓` 框内移选(越界则滚)、`→`/`ENTER` 进子框、`←`/`ESC` 关最右框。
**内存**:**仅屏上框持行对象**,框关闭(返回)即毁其行 → 池有界(屏上框数 × 每框行数,与深度无关),详见 §七。
```text
┌──────────┬───────┐
│ 装置信息 │ │
│ 实时数据▶ │ │
│▸参数定值 ├───────┼───────┐
│ 三遥设置▶ │整定▶ │参数 │ ← 中框顶边 = "参数定值"行顶;右框顶边 = "整定"行顶(本例同高)
│ 装置维护▶ │查看▶ │定值 │
│ 通讯参数▶ ├───────┤控制字 │
│ 记录查询▶ │ │软压板 │
└──────────┴───────┘ └───────┘
子框左缘 = 父框右缘(共享边框列、零间隙);宽按内容自适应;不钉屏幕右缘。
超屏底 → 上移使子框底边对齐父"选中行"行底(可叠到屏顶状态行/装饰区)。
```
## 五、与旧实现的差异(2026-09-04 已实现)
> 旧 `ui_menu.c` 的 `g_menu_tab[]` 为"层级(level)+连续兄弟段"结构,现已整体替换为 `ui_menu_tab.c`(父子索引表,生成物)。下表为旧→新条目差异记录(已落地)。
| 一级 | 现代码子项 | 目标子项(本文) |
|---|---|---|
| 实时数据 | 交流量、直流量、**开入量** | 交流量、直流量、**遥信量** |
| 参数定值 | **定值**{定值/遥调/零压/电压}、查看{定值/遥调/零压/电压} | **整定**{参数/定值/控制字/软压板}、查看{参数/定值/控制字/软压板} |
| 三遥设置 | 测量值、遥调系数、遥测系数、双字节、装置遥调 | 遥测死区、遥测系数、遥信类型、遥信防抖、双点遥信 |
| 装置维护 | **时间设置**、强制复归、手动录波、**自动录波** | **时钟设置**、强制复归、手动录波、**清除记录** |
| 通讯参数 | **通讯参数**、**网络配置**、SNTP设置 | **通讯设置**、**网口设置**、SNTP设置 |
| 记录查询 | SOE记录、事故记录、操作记录、**告警记录、变位告警、遥信记录**、自检记录、**定值记录、录波记录** | SOE记录、事故记录、操作记录、**保护告警、保护启动、遥控记录**、自检记录、**运行记录、运行报告** |
| (根外) | 厂家设置/版本信息(旧表含,已排除) | 不列入 7 个一级条目 |
**新增层级**:参数定值下"整定/查看"为 L2 菜单,其子项(参数/定值/控制字/软压板)为 **L3**——节点表需重排。
---
## 六、菜单树数据结构(C,存储 + 导航解耦)
> 目标:把"菜单长什么样"(数据)与"怎么进出"(导航逻辑)彻底解耦;改菜单只改**一张表**,导航代码不动。
### 6.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 叶子由"是否有子项"自动推导**:加子项→自动出现 ▶;删光子项→自动变叶子。无需单独的类型字段,替换时零维护。
### 6.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`(见 §6.6);字段/语义与此一致。
### 6.3 完整 42 节点表(按 §三 节点定义表落地)
> 产物 `ui_menu_tab.c`(§6.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 L0 菜单(根) */
{ 0, "装置信息" }, /* 1 L1 叶子 */
{ 0, "实时数据" }, /* 2 L1 菜单 */
{ 2, "交流量" }, /* 3 L2 叶子 */
{ 2, "直流量" }, /* 4 L2 叶子 */
{ 2, "遥信量" }, /* 5 L2 叶子 */
{ 0, "参数定值" }, /* 6 L1 菜单 */
{ 6, "整定" }, /* 7 L2 菜单 */
{ 7, "参数" }, /* 8 L3 叶子 */
{ 7, "定值" }, /* 9 L3 叶子 */
{ 7, "控制字" }, /* 10 L3 叶子 */
{ 7, "软压板" }, /* 11 L3 叶子 */
{ 6, "查看" }, /* 12 L2 菜单 */
{ 12, "参数" }, /* 13 L3 叶子 */
{ 12, "定值" }, /* 14 L3 叶子 */
{ 12, "控制字" }, /* 15 L3 叶子 */
{ 12, "软压板" }, /* 16 L3 叶子 */
{ 0, "三遥设置" }, /* 17 L1 菜单 */
{ 17, "遥测死区" }, /* 18 L2 叶子 */
{ 17, "遥测系数" }, /* 19 L2 叶子 */
{ 17, "遥信类型" }, /* 20 L2 叶子 */
{ 17, "遥信防抖" }, /* 21 L2 叶子 */
{ 17, "双点遥信" }, /* 22 L2 叶子 */
{ 0, "装置维护" }, /* 23 L1 菜单 */
{ 23, "时钟设置" }, /* 24 L2 叶子 */
{ 23, "强制复归" }, /* 25 L2 叶子 */
{ 23, "手动录波" }, /* 26 L2 叶子 */
{ 23, "清除记录" }, /* 27 L2 叶子 */
{ 0, "通讯参数" }, /* 28 L1 菜单 */
{ 28, "通讯设置" }, /* 29 L2 叶子 */
{ 28, "网口设置" }, /* 30 L2 叶子 */
{ 28, "SNTP设置" }, /* 31 L2 叶子 */
{ 0, "记录查询" }, /* 32 L1 菜单 */
{ 32, "SOE记录" }, /* 33 L2 叶子 */
{ 32, "事故记录" }, /* 34 L2 叶子 */
{ 32, "操作记录" }, /* 35 L2 叶子 */
{ 32, "保护告警" }, /* 36 L2 叶子 */
{ 32, "保护启动" }, /* 37 L2 叶子 */
{ 32, "遥控记录" }, /* 38 L2 叶子 */
{ 32, "自检记录" }, /* 39 L2 叶子 */
{ 32, "运行记录" }, /* 40 L2 叶子 */
{ 32, "运行报告" }, /* 41 L2 叶子 */
};
```
> `UI_MENU_TAB_COUNT`(=42)与 `MENU_ROOT` 在 `ui_menu_tab.h` 定义。
### 6.4 导航 API(实现:`ui_menu_tab.h` 生成物,替换菜单零改调用方)
设计阶段拟名 `menu_get_children/…`;实现采用模块前缀 `ui_menu_tab_*`(避免与逻辑层 `menu_*` 函数名混淆),参数 `uint8_t`(2026-09-04 由 16→8 位决定,见变更记录),实现随表生成在 `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 下标均有效 */
```
**建框 = 对"当前层节点"做一次过滤**(条目**现算不落地**,见 §七):
```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++) {
/* 在框体内建一行:文本 = ui_menu_tab_name(kids[i]);
箭头 = ui_menu_tab_has_children(kids[i)](有子项才带 ▶) */
}
```
> 旧"level + 连续兄弟段"推断已整体移除(其根页遇第二个 `level==0` 即截断的缺陷不复存在);导航/显示一律按 `parent == 节点` 过滤,**改名/换序/增删子项均不影响导航逻辑**。
### 6.5 启动自检(已实现:`ui_menu_tab_valid()`)
> 实现随表生成在 `ui_menu_tab.c`:校验**非根节点 parent 下标均 < `UI_MENU_TAB_COUNT`**(捕获表内笔误/生成器缺陷)。
> 由 `ui_menu_init()` 首行调用;失败 → 打 `DBG_ERROR("menu table: INVALID…")` 并放弃建菜单(返回 -1)。
> 设计稿曾列"无环/全连通"校验;实现从简为 parent 界校验——生成器已保证 parent 恒为"上级节点下标"(结构上无环),界校验足以兜底。
### 6.6 声明式生成器(**已实现**,呼应字体生成 `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
```
> **已实现**(2026-09-04):`ui_menu_tab.c/.h` 已生成(42 节点:9 菜单 + 33 叶子,最深 L3),`ui_menu.c` 不再持有本地表,导航/显示一律走 `ui_menu_tab_*` 查询 API(§6.4)。
> **编码陷阱(已踩)**:产物含中文**字符串字面量**,必须 **UTF-8 带 BOM**(同 `ui_menu.c`)。
> 无 BOM 时 cl.exe(代码页 936/GBK)会按 GBK 误读 UTF-8 字节 → 字符串解析错乱(C2001/C4819)。`gen_menu.py` 已用 `utf-8-sig` 输出。
### 6.7 与现有 `g_menu_tab[]` 的迁移映射
| 现代码(`ui_menu.c`) | 新结构 | 说明 |
|---|---|---|
| `{ uint8_t level; const char *name; }` | `{ uint8_t parent; const char *name; }` | 用 `parent` 取代 `level`,父子关系显式 |
| `menu_page_build()` 按 level 连续段取子项 | `ui_menu_tab_children(node,…)` 按 `parent==node` 取 | 导航逻辑与数据解耦 |
| 根页遇第二个 `level==0` 截断 | 根页 = 主界面(node 0)子项(`parent==0`),走同一 `ui_menu_tab_children` 路径,天然无截断 | 消除旧表根外"厂家设置"隐患;根页无需特判 |
| 菜单/叶子靠 level 推断 | `ui_menu_tab_has_children()` 按"是否有子项"推导 | ▶ 自动出现/消失,零维护 |
> **迁移已于 2026-09-04 完成**:`ui_menu.c` 不再含本地 `g_menu_tab`(level 版已删除),旧 `menu_page_build()` 的 level 段推断不复存在;新 `ui_menu.c` 为纯导航状态机(见 §八)。
---
## 七、导航运行时数据结构(完整清单)
> 导航用到的数据分**两层**:**静态层**(Flash/const,菜单树,只读查询)+ **运行时层**(RAM,导航状态 + LVGL 对象)。
> **核心原则:能推导的就不存**——某页的条目列表可由 `g_menu_tab` 的 `parent==节点` 随时重算;
> 故**只持久化"不可推导"的导航历史(路径 + 各层选中)**,页面内容进出时按需重建。
### 7.1 静态层(Flash,const,只读)
| 符号 | 定义处 | 内容 | 占用 |
|---|---|---|---|
| `ui_menu_node_t` | `ui_menu_tab.h` | `{ uint8_t parent; const char *name; }` | 类型定义 |
| `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 项) | 宏 |
> 导航**只读**它(`ui_menu_tab_children`/`ui_menu_tab_has_children`/`ui_menu_tab_name` 都是对它的过滤/查询),从不修改。
### 7.2 运行时层(RAM)
> 归属:**A 在逻辑层 `ui_menu.c`**(唯一持有导航状态的文件);**B~G 在显示层 `ui_menu_view.c`**(持有全部 LVGL 对象,无导航状态)。
**A. 导航历史 —— 唯一"必须持久"的本质状态(A 组 9 B,加 F 组 `s_scroll` 共 17 B,见 §7.3)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_path[]` | `uint8_t[MENU_PAGE_MAX]` | 4 B | **路径**:`s_path[d]`=第 d 层"展示的节点下标"(`s_path[0]=0` 主界面);即"从根到当前(最右)框"的节点链 |
| `s_sel[]` | `uint8_t[MENU_PAGE_MAX]` | 4 B | **各层选中下标**:返回时还原该层高亮(选中不跳) |
| `s_depth` | `uint8_t` | 1 B | **当前深度**(0=根页)= 路径长度−1 |
**B. 持久页面容器(每深度一个,一次创建、常驻、不销毁)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_cont[]` | `lv_obj_t*[MENU_PAGE_MAX]` | 16 B | 4 个全屏容器(每深度一个,显示层);透明常驻、**从不切换可见性**(框在容器内建/毁,容器永不删,防焦点对象悬空);焦点经 `lv_group_focus_obj` 在容器间移动 |
**C. 活跃框条目对象(按层:每个"屏上框"一套,进时建、退/关闭时毁)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_row[d][i]` | `lv_obj_t*[MENU_PAGE_MAX][MENU_ITEM_MAX]` | 256 B | 各框各行容器(整行反色高亮的载体),仅屏上框 `d` 有效 |
| `s_row_text[d][i]` | `lv_obj_t*[MENU_PAGE_MAX][MENU_ITEM_MAX]` | 256 B | 各行文本 label |
| `s_row_arrow[d][i]` | `lv_obj_t*[MENU_PAGE_MAX][MENU_ITEM_MAX]` | 256 B | 各行箭头 label(叶子=NULL) |
> **只给"屏上的框"建行对象**(每框 ≤ `MENU_ITEM_MAX`=16 行);返回时销毁被关闭的最右框行,既有屏上框不动、无需重建,
> **已关闭的框不留行对象** → LVGL 池只占**屏上各框**(2~3 框),比"4 页全常驻"省池内存。行句柄按层开(3 组共 768 B),仅填屏上框。
**D. 主界面(根框)装饰**
| 字段 | 类型 | 用途 |
|---|---|---|
| `s_root_deco` / `s_root_status` | `lv_obj_t*` ×2 | 根框装饰:顶部"浅 V"水平段(两腿为局部对象,不落句柄)+ 状态行 label("当前模式 : 无模式");根框框体复用 `s_frame_box[0]`(无独立句柄) |
> 子框**无逐框页头/返回按钮**(返回靠 `←` 键,框即紧凑列表)——较旧"单页 + 页头"模型省去 `s_header`/`s_back_btn`/`s_title`。
**E. 样式(一次性初始化,常驻)**
`s_style_flat`(透明无框,剥离主题 card)、`s_style_selected`(选中整行反色)、`s_style_frame`(框体 1px 白边框)、`s_style_scrollbar`(滚动条,4px 宽)。
**F. 每框显示状态(按层)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_scroll[]` | `uint16_t[MENU_PAGE_MAX]` | 8 B | 每框滚动偏移(用户 `↑`/`↓` 滚;该框关闭后再进入、行对象重建时按此还原位置) |
> 每框的 **x 坐标 / 顶边 / 框宽** 是**现算**的(右贴靠布局:子框左缘=父框右缘 + 顶对齐/底对齐上移,见 §四 ⑧),不落导航状态;只有"用户滚动到哪"(`s_scroll[]`)需持久。
**G. 框几何缓存(显示层,"现算"结果的按层缓存)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_frame_box[]` | `lv_obj_t*[MENU_PAGE_MAX]` | 16 B | 每深度框体对象(d=0 持久;d>0 进入时建、返回时毁) |
| `s_frame[]` | `menu_frame_t`(built/sel/item_cnt/x/y/w/h)×4 | ≈80 B | 每框几何缓存:子框对齐要用**父框刚算出**的 x/w/选中行,故按层留一份;随时可由 `s_path`/`s_sel` 重算,**不属于导航状态** |
### 7.3 存 vs 算(关键区分)
| 数据 | 存 / 算 | 依据 |
|---|---|---|
| 路径(在哪些层) | **存** `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_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[d][*]`;恢复滚动位 |
| **移**(↑/↓) | `s_sel[d]`、`s_scroll[d]`、`s_row[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]` | 16 B |
| 行句柄 `s_row[d][i]`+`s_row_text`+`s_row_arrow` `[4][16]` | 768 B |
| 根框装饰指针(2 个:水平段 + 状态行) | 8 B |
| 框体句柄 `s_frame_box[4]` | 16 B |
| 框几何缓存 `s_frame[4]`(`menu_frame_t`) | ≈80 B |
| 样式 `s_style_*`(4 个,一次性) | ~400 B |
| **小计** | **~1.4 KB** |
**LVGL 池(动态,`LV_MEM` 40 KB 预算内)**:占**屏上各框**对象 = 屏上框数(2~3) × 行数 ×(行容器+文本+箭头),
典型 ≈ **4–8 KB**,最坏 3 框满 16 行 ≈ 15 KB(仍远在 40 KB 内)。
> 对比现有 `s_pages[4]`(每页各带行对象、**4 页全常驻**):本方案行句柄按层开(768 B,与旧相当),
> 但 **LVGL 池只给屏上框建行**(2~3 框、关闭即毁)→ 池峰值比"4 页全常驻"省约 1/4;静态 RAM 因句柄按层略升(~0.75→~1.3 KB),仍在余量内。
### 7.6 与旧实现的对照(迁移已完成 2026-09-04)
| 旧实现(原 `ui_menu.c` 单文件模型) | 现实现(本节) | 实现结果 |
|---|---|---|
| `ui_menu_page_t s_pages[4]`(每页各带 `row[16]/row_text[16]/row_arrow[16]/item_node[16]/node/sel/depth`,4 页全常驻) | `s_path[]`+`s_sel[]`+`s_depth`+`s_scroll[]`(17 B)+ 按层 `s_row[d][i]` + `s_cont[4]`,**仅屏上框建行** | 路径/选中/滚动按层存;行只给屏上框(关闭即毁) |
| `page->item_node[16]`(存条目下标) | 不存,建框时 `ui_menu_tab_children(s_path[d])` 现算 | 去掉 `item_node[]` 存储 |
| `page->node`(本页节点) | `s_path[d]`(按层存) | 等价,改按层 |
| `page->sel` / `page->depth` | `s_sel[d]` / `s_depth` | 按层存选中 / 用深度代替 `s_cur` 指针 |
| `s_cur`(当前页指针) | `s_depth`(当前深度) | 深度索引替代指针 |
> **一句话**:导航**不需要**为每框存"条目列表/位置"——只存**路径 + 各层选中 + 各框滚动位**(17 B),
> 框的条目/名字/位置全现算、行只给屏上框;改菜单(§6.6)只动静态表,运行时结构零改动。
---
## 八、实现架构与关键实施细节(2026-09-04 与代码同步)
> 本节把 §四/§六/§七 设计同步到**实际落地代码**(2026-09-04 实现;模拟器 7 状态截图对照参考图 01~07 + `@build` 0 错 0 警已验证)。
### 8.1 三层文件拆分(高内聚低耦合)
| 层 | 文件 | 职责 | 持 LVGL 对象? |
|---|---|---|---|
| 数据层 | `Drivers/BSP/UI/ui_menu_tab.c/h`(生成物) | `g_menu_tab[]` 父子索引表 + 4 个查询 API(§6.4) | 否 |
| 显示层 | `Drivers/BSP/UI/ui_menu_view.c/h` | 级联框渲染:样式 / 4 层容器 / 框体 / 行 / 根框装饰;几何现算按层缓存(§7.2 G) | 是(全部) |
| 逻辑层 | `Drivers/BSP/UI/ui_menu.c/h` | 导航状态(`s_path/s_sel/s_depth/s_scroll`,17 B,§7.2 A/F)+ 键语义;单向驱动显示层建/毁/刷新;唯一公开入口 `int ui_menu_init(void)`(uiTask 调用) | 否 |
- 依赖方向单向:逻辑 → 显示;两者都用数据层查询 API;显示层无导航状态、逻辑层无 LVGL 对象
- 3 个文件 MCU / PC 模拟器**共用**(双端注册:Keil `.uvprojx` + `simulator/CMakeLists.txt`,见 `UI_设计架构.md` §1.4 约束 5)
### 8.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`)。
### 8.3 几何计算(显示层 `ui_menu_view_frame_build()`)
```text
框宽 w = 各行 (PAD_L + 文本_w + [菜单: GAP + ▶_w] + PAD_R) 取最大 + 2×BORDER
文本/▶ 宽经 lv_text_get_width 取字体实际步进(⚠️ 见 8.8 陷阱 1)
实测:根框(7行4字) = 66 | 2字+箭头框 = 42 | 3字无箭头框 = 46
| 4字无箭头框 = 54 | 空终点框 = 6 (2×BORDER + 2×PAD)
子框 (d>0) X: x = 父.x + 父.w − 1 (左缘 = 父框右缘,共享边框列、零间隙)
子框 (d>0) Y: y = 父.y + BORDER + 父sel×ROW_H (顶对齐父框"选中行"行顶)
y + h > 160 → y = 选中行行顶 + ROW_H − h (上移使本框底边对齐选中行行底)
y < 0 → y = 0(顶钳制;仍溢出则框高截到可见区、行改框内滚动)
框高 h = 行数 × ROW_H + 2×BORDER (按内容,不强顶屏底)
```
### 8.4 对象模型与选中态
- 屏幕根容器(全屏 flat)→ 4 层**持久全屏容器** `s_cont[0..3]`(每深度一个,`LV_OBJ_FLAG_IGNORE_LAYOUT` + 绝对定位,透明,永不删;级联框须同屏叠加,层间不用 flex 堆叠)
- 根框体 `s_frame_box[0]` 持久(位置 (0,44)、宽自适应);子框体进入时建 / 返回时毁(`lv_obj_create(s_cont[d])` + flat + 边框样式,`IGNORE_LAYOUT` 绝对定位)
- 框体:纵向 flex 堆行 + 自动滚动条(`LV_SCROLLBAR_MODE_AUTO`);行超框高才框内滚动
- 行 = `lv_obj` 容器(整行宽、高 15;整行反色高亮的载体)+ 文本 label(左留白 2)+ 箭头 label(紧跟文本、间距 2;叶子无箭头);文本/箭头 `IGNORE_LAYOUT` 行内绝对定位,静态字符串(`lv_label_set_text_static`)
- 选中行 = `LV_STATE_CHECKED` → 整行反色(白底黑字),**箭头随反色变黑**(参考图 02/04 实测:选中行保留 ▶);非选中行透明底白字
- 根框装饰:两腿"浅 V"(`lv_line` 宽 3,点集 (7,7)-(16,17) / (158,7)-(145,17))+ 水平线(宽 2,(16,17)-(145,17))+ 状态行 label(17,20)
- 空终点框:叶子 ENTER 后建 w=6、h=2 的占位框(无行),仅 ESC 可返回
### 8.5 键路径(两端一致)
```text
远端按键 / SDL 按键 → keypad 输入设备(LV_INDEV_TYPE_KEYPAD)
→ 默认输入组 lv_group → 焦点对象 = 最右框的 s_cont[d]
→ LV_EVENT_KEY → menu_key_cb(user_data = depth)→ menu_key_handle(d, key)
UP/DOWN:s_sel[d] 环绕 → frame_refresh(高亮 + 跟随滚动)→ 滚动位回读 s_scroll[d]
ENTER/RIGHT:menu_open(选中子节点) —— 叶子同样压层(空终点框)
ESC/LEFT:menu_back()(毁最右框、焦点回父框;根框不响应)
```
- 4 层容器同组,任一时刻仅最右容器获焦(进/退时 `lv_group_focus_obj` 显式移动),无焦点争抢
- 模拟器无头验证走同一路径:`lvgl_sim.exe --keys <seq> <shot.bin>` 经 `lv_group_send_data` 注键(seq 字符:u=↑ d=↓ l=← r=→ e=Enter s=Esc)
### 8.6 初始化时序(`ui_menu_init()`,uiTask 等 NET_READY 后调用)
```text
1. ui_menu_tab_valid() 表自检(parent 越界 → 打 ERROR 并放弃建菜单,返回 -1)
2. 清零导航状态(s_path=0 / s_sel=0 / s_scroll=0 / s_depth=0)
3. ui_menu_view_init():样式 → 屏幕根容器 → 4 层容器 → 根框装饰 + 状态行 → 根框框体
4. 各层容器:lv_group_add_obj(默认组) + 挂 menu_key_cb(user_data = depth)
5. ui_menu_view_frame_build(0) 建根框行 + focus(0)
6. lv_mem_monitor 打印建前/建后 LVGL 池(实测根框建完池占用 ~13 KB,见下)
```
日志标记:`UI menu init: OK`(uiTask 打印)——`@test` 回归可作 Required 标记。
LVGL 池实测(模拟器 40 KB 池):根框建完 `used=13312 B`,最深三级级联增量远小于池余量。
### 8.7 验证方法(分级)
| 环节 | 方法 |
|---|---|
| 模拟器无头 | `lvgl_sim.exe --screenshot shot.bin` / `--keys <seq> shot.bin`;I1 像素(102400 B)转 PNG(PIL)目检 |
| 对照参考图 | 7 状态(根页 + 6 个一级子框)逐一对照参考图 01~07;反色行/框几何用像素统计核验(2026-09-04 全通过,偏差见 §8.8 注 5) |
| 编译 | `@build`(ARMCC,0 错 0 警,强制) |
| 真机 | `@test`(Required 含 `UI port init: OK;UI menu init: OK`;屏幕内容仍需人工目检) |
### 8.8 实施陷阱与保真度说明(2026-09-04 踩坑记录)
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. 字体缺字形 = 方框:菜单树更新后旧字符集缺 整/控/软/板/死/区/类/防/抖/点/钟/清/口/保/启/运/报 17 字;重跑 `extract_cn.py` 同步字符集(78 唯一字)+ 重生成字体修复(流程见 `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)。
---
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-09-03 | 初始版本:依据用户 7 屏主菜单截图定义完整 4 级逻辑层级(42 节点)+ 导航逻辑 + 与现有 `g_menu_tab` 差异对照 |
| 2026-09-03 | 按范围调整:移除"内容页类型"相关内容,聚焦菜单层级(节点/父子/层级/导航);新增 `子项数` 列;标注"查看"子项为推测待确认 |
| 2026-09-03 | **查看(#12)子项经用户确认**与"整定"同构(参数/定值/控制字/软压板),由推测转为正式定义,移除全部 ⚠ 标记与待确认节 |
| 2026-09-03 | 新增 §六"菜单树数据结构":选型扁平"父子索引表"(邻接表/关系表模型,对比指针树/首子-兄弟/堆式),给出 `ui_menu_node_t` + 完整 42 节点表 + 导航 API(`menu_get_children`/`menu_has_children`/`menu_get_name`)+ 启动自检 + 声明式生成器 + 与现有 `g_menu_tab` 迁移映射 |
| 2026-09-03 | **声明式生成器落地**(§6.6 由"可选"转"已实现"):新增 `ui_menu_tree.txt`(缩进即层级源)+ `tools/menu_gen/gen_menu.py`(同 `make_font.py` 模式),生成 `Drivers/BSP/UI/ui_menu_tab.c/.h`(42 节点:9 菜单 + 33 叶子,最深 L3);已跑通并校验 parent 下标/连通性/子项数全部正确 |
| 2026-09-04 | §四 新增"**导航走查**":用真实 `g_menu_tab` 下标逐步演示"移/进/退"如何由 `parent==当前节点` 一次过滤驱动(当前页=过滤、进=压页/终点、退=弹页、导航态=当前节点链),并修正根页模型为"主界面 node 0 的子项"(§6.2/6.7 同步,去掉"虚拟根页"表述) |
| 2026-09-04 | 新增 §七"导航运行时数据结构(完整清单)":静态层(`g_menu_tab` 只读)+ 运行时层(导航历史 `s_path`+`s_sel`+`s_depth`=13 B、持久容器 `s_cont[4]`、单套活跃行 `s_row*[]`、页头/装饰、样式);"存 vs 算"区分、操作×字段读写表、内存预算、与现有 `s_pages[4]` 收敛对照 |
| 2026-09-04 | 改名 `menu_is_menu` → `menu_has_children`(原名"menu 是不是 menu"含糊;新名=该节点有无子项);§四 路径/代码映射由旧 `s_pages[]` 统一为 §七 的 `s_path[]`/`s_depth`/`s_sel[]`/`s_row*[]` 模型,消除 §四/§七 不一致 |
| 2026-09-04 | 改名 `menu_children` → `menu_get_children`、`menu_name` → `menu_get_name`(裸名词改"动词+对象",API 统一 `has_`/`get_` 风格);§6.4 建页片段改用 `s_path`/`s_depth`/`s_cont` 现算模型(条目不落地) |
| 2026-09-04 | §6.4 补"签名约定"说明:`node`/`out` 取 `uint16_t` 因与 `parent`(须装 `0xFFFF` 根哨兵)同域、**非容量所需**(42 下标 8 位够);`max`=出参缓冲容量(防越界 + 对齐单页行数,今日 9≤16 不触发截断) |
| 2026-09-04 | **索引位宽 16→8 位**:`MENU_ROOT` 由 `0xFFFF` 降为 `0xFF`,`parent`/`node`/`out`/`s_path[]` 均改 `uint8_t`(菜单上限 255 项,远够);路径历史 13→9 B、省 ~20 B RAM,Flash 不变(结构体因指针仍 8 B/项);双护栏=生成器 `节点数>255 报错` + 头文件 C99 断言 `menu_tab_count_check`。生成器已改并重生成 `ui_menu_tab.c/.h`,MSVC /W4 编译 + 导航测试(根 7 / 整定 4 / 查看 4 / 记录查询 9)通过;同步 §四/§6.2/§6.4/§6.5/§七。取代上一条"签名约定(uint16_t)"决定 |
| 2026-09-04 | 修正 §四"进入/返回示例":返回行节点顺序原与进入行相反(参数在左)致 `←` 被读成"前进"方向;改为与进入行**同序**(主界面 L0 在左…参数 L3 在右)+ `←`,并补"怎么读"注(`→`/`←` = 进/退动作方向;返回 = 从最右 参数(L3) 沿 `←` 一级级退回最左 主界面(L0)) |
| 2026-09-04 | **子页面显示模型定稿(横向级联子框)**:§四 新增 ⑧"显示模型"——横向树,每级一框在上一级**右侧**、顶边对齐上级"选中行"行顶;水平**右锚定**(最深框钉右缘、父框超宽滚出左、行按屏上/外建毁)+ 框宽文字自适应 + **无动画**;垂直每框独立(顶边=上级选中行行顶、框高按内容、**仅底边>160 才上移**贴屏底、比整屏高则 `↑`/`↓` 滚);焦点=最右框(`↑`/`↓` 移选/滚、`→`/`ENTER` 进、`←`/`ESC` 关最右框)。§七 同步:行对象按层 `s_row[d][i]` 仅屏上框建、去掉逐框页头(`s_header`/`s_back_btn`/`s_title`)、新增每框滚动 `s_scroll[]`(8 B)、"存 vs 算"增 滚动=存/位置=算、持久态 9→17 B、内存预算(静态 ~0.75→~1.3 KB、LVGL 池=屏上 2~3 框)、对照表更新 |
| 2026-09-04 | **§四⑧ 按参考图 02~07 像素实测校准**(设备 px,误差 ≤3px):层级树/命名/箭头/选中态全部与图一致 ✓;显示模型两处修正——水平由"右锚定(钉屏幕右缘)"改为"**子框左缘=父框右缘**(共享边框列、零间隙)+ 宽自适应"(实测各子框均贴父框右缘 x≈67/106,最深框止于 x≈113~127 而非屏右缘);垂直溢出由"上移使底边贴屏底"改为"**上移使子框底边对齐父选中行行底**"(实测三遥 top=26/装置维护 top=54/通讯参数 top=82/记录查询 top=12 均吻合,且可叠到屏顶状态行区、顶部不钳制)。本菜单最深路径 ≈155<160 不会溢出;>160 行为与"根页框宽 99 vs 68"列为待确认。§7.2/§7.3/§7.4 "右锚定"表述同步 |
| 2026-09-04 | **实现落地,本文档与代码同步**:三层拆分实现(`ui_menu_tab` 生成物 + 新增 `ui_menu_view` 显示层、`ui_menu` 瘦身为纯状态机,§8.1);§四⑦/§6.4/§6.5/§6.6/§6.7 函数与 API 名同步为实际 `ui_menu_tab_*`/`ui_menu_view_*`/`menu_open|menu_back|menu_key_handle`;§五"待实现"→"已实现";§七 修正为实际字段(容器从不切 HIDDEN、`s_style_frame`、新增框几何缓存 `s_frame[]`,§7.2 G);§四⑧ 实测值区分"参考图/实现"两套(实现:根框 66、子框 42/46/54/6,顶 28/58/88/13,偏差入 §8.8 注 5),>160 溢出"待确认"落定为实现行为"屏缘裁切";新增 **§八 实现架构与关键实施细节**(布局常量/几何公式/对象模型/键路径/初始化时序/分级验证/6 条陷阱与保真度说明);验证:模拟器 7 状态对照参考图 01~07 + `@build` 0 错 0 警 |