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
+6
View File
@@ -10,6 +10,12 @@
- 退出码:0 = 0 错误 0 警告 | 1 = 有警告或错误(查看 `MDK-ARM/build_log.txt`)
- ⚠️ **旧绝对路径陷阱**:仓库曾位于 `D:\Code\DTU 程序\STM32F4-Base`,`.opencode/command/*.md` 与 `test/run_boot_test.ps1` 中若出现该路径一律视为失效,改用仓库相对路径(如 `& "MDK-ARM\build.bat"`)
## Python 环境(跑脚本用)
- PATH 默认 `python` 是 msys64 的(`C:\msys64\ucrt64\bin\python.exe`,无 pip/fonttools),**不要用它**跑依赖第三方库的脚本
- 本机已验证可用解释器:conda base 环境 `C:\ProgramData\anaconda3\python.exe`(Python 3.12.4,fontTools 4.51.0 预装)
- 字体生成需 fontTools:`& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py`;`extract_cn.py` / `tools/menu_gen/gen_menu.py` 纯标准库,任意 python3 均可
## 硬件
| 外设 | 接口 | 引脚 |
+165 -210
View File
@@ -1,7 +1,7 @@
/*******************************************************************************
* Size: 12 px
* Bpp: 1
* Opts: --font C:\Users\Administrator\AppData\Local\Temp\simsun_regular.ttf --size 12 --bpp 1 -r 0x20-0x7E --symbols ▶三主事交件位作信值元入全出制前动厂压参双变号告回复字定实家开式强归当录恢息手护据操故数无时显本查检模波流测版电界直看示系络维网置自节装警认讯记设询调返通遥部配量间零面默: --no-compress --format lvgl --lv-font-name ui_font_cn_12 -o D:\Code\DTU Code\STM32F4-Base\Drivers\BSP\UI\ui_font_cn_12.c
* Opts: --font C:\Users\ADMINI~1\AppData\Local\Temp\simsun_regular.ttf --size 12 --bpp 1 -r 0x20-0x7E --symbols ▶三主事交作保信值制前动区压参双口启告型复字定实式强归当录息手抖护报据控操故数整无时板查检模死波流测清点界直看类系维网置自行装警讯记设询软运通遥量钟防除面: --no-compress --format lvgl --lv-font-name ui_font_cn_12 -o D:\Code\DTU Code\STM32F4-Base\Drivers\BSP\UI\ui_font_cn_12.c
******************************************************************************/
#ifdef LV_LVGL_H_INCLUDE_SIMPLE
@@ -327,21 +327,16 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x14, 0x50, 0x50, 0x4, 0x1, 0x40, 0xc6, 0x60,
0x30,
/* U+4EF6 "件" */
0x21, 0x5, 0x21, 0x24, 0x27, 0xfd, 0x10, 0x82,
0x10, 0x42, 0xff, 0x41, 0x8, 0x21, 0x4, 0x20,
0x80,
/* U+4F4D "位" */
0x22, 0x4, 0x21, 0x0, 0x2f, 0xfc, 0x0, 0x90,
0x92, 0x12, 0x22, 0x44, 0x88, 0x91, 0x4, 0x2f,
0xf0,
/* U+4F5C "作" */
0x14, 0x2, 0x80, 0x9f, 0x95, 0x6, 0x21, 0x47,
0x88, 0x81, 0x10, 0x23, 0xe4, 0x40, 0x88, 0x11,
0x0,
/* U+4FDD "保" */
0x10, 0x2, 0xf8, 0x91, 0x12, 0x26, 0x7d, 0x42,
0xb, 0xf9, 0x8, 0x23, 0x84, 0xa8, 0xa4, 0x90,
0x80,
/* U+4FE1 "信" */
0x22, 0x4, 0x21, 0x7f, 0xa0, 0xc, 0xfc, 0x80,
0x13, 0xf2, 0x0, 0x4f, 0xc9, 0x9, 0x3f, 0x24,
@@ -352,25 +347,6 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x92, 0x12, 0x7e, 0x48, 0x49, 0xf9, 0x21, 0x2f,
0xf0,
/* U+5143 "元" */
0x7f, 0xc0, 0x0, 0x0, 0x0, 0xf, 0xfe, 0x22,
0x4, 0x40, 0x88, 0x21, 0x28, 0x26, 0x3, 0x80,
/* U+5165 "入" */
0x30, 0x1, 0x0, 0x10, 0x2, 0x0, 0xa0, 0x14,
0x2, 0x80, 0x88, 0x11, 0x4, 0x11, 0x1, 0x40,
0x10,
/* U+5168 "全" */
0x4, 0x1, 0x40, 0x44, 0x10, 0x4c, 0x6, 0x7f,
0x1, 0x0, 0x20, 0x7f, 0xc0, 0x80, 0x10, 0x7f,
0xf0,
/* U+51FA "出" */
0x4, 0x8, 0x89, 0x11, 0x22, 0x27, 0xfc, 0x8,
0x1, 0x4, 0x21, 0x84, 0x30, 0x87, 0xff, 0x80,
0x10,
/* U+5236 "制" */
0x10, 0x2a, 0x5, 0xf4, 0xc8, 0x9f, 0xf2, 0x22,
0x5f, 0x4a, 0xa9, 0x55, 0x2a, 0x85, 0x70, 0x88,
@@ -386,9 +362,9 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x50, 0x4a, 0x89, 0x89, 0x3f, 0x44, 0x8, 0x82,
0x60,
/* U+5382 "厂" */
0x7f, 0xe8, 0x1, 0x0, 0x20, 0x4, 0x0, 0x80,
0x10, 0x2, 0x0, 0x40, 0x8, 0x2, 0x0, 0x0,
/* U+533A "区" */
0xff, 0xe0, 0xa, 0xa, 0x44, 0x8a, 0x21, 0x8,
0xa2, 0x44, 0xa0, 0xa0, 0xf, 0xfc,
/* U+538B "压" */
0x7f, 0xe8, 0x1, 0x8, 0x21, 0x4, 0x20, 0xbf,
@@ -403,24 +379,24 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xfb, 0xe1, 0x44, 0x28, 0xc5, 0x15, 0x14, 0xa2,
0x88, 0x21, 0x4, 0x51, 0x4a, 0x4a, 0x10, 0x80,
/* U+53D8 "变" */
0x4, 0x1f, 0xfc, 0x44, 0x28, 0xa9, 0x12, 0x0,
0x1f, 0xe1, 0x4, 0x11, 0x1, 0xc0, 0xc6, 0x60,
0x30,
/* U+53E3 "口" */
0xff, 0xc0, 0x60, 0x30, 0x18, 0xc, 0x6, 0x3,
0x1, 0xff, 0xc0, 0x40,
/* U+53F7 "号" */
0x3f, 0x84, 0x10, 0x82, 0x1f, 0xc0, 0x1, 0xff,
0xc4, 0x1, 0x0, 0x3f, 0xc0, 0x8, 0x1, 0x0,
0xc0,
/* U+542F "启" */
0x4, 0x0, 0x40, 0xff, 0x90, 0x12, 0x2, 0x7f,
0xc8, 0x1, 0xff, 0x30, 0x2a, 0x5, 0x7f, 0xc8,
0x10,
/* U+544A "告" */
0x4, 0x4, 0x80, 0xff, 0x22, 0x0, 0x41, 0xff,
0xc0, 0x3, 0xfe, 0x40, 0x48, 0x9, 0xff, 0x20,
0x20,
/* U+56DE "回" */
0xff, 0xe0, 0x18, 0x6, 0x79, 0x92, 0x64, 0x99,
0xe6, 0x1, 0x80, 0x7f, 0xf8, 0x4,
/* U+578B "型" */
0x0, 0x2f, 0xd4, 0x92, 0x92, 0x5f, 0xea, 0x49,
0x51, 0xc, 0x3, 0x4, 0xf, 0xf8, 0x10, 0x7f,
0xf0,
/* U+590D "复" */
0x3f, 0xe8, 0x2, 0xff, 0x10, 0x23, 0xfc, 0x40,
@@ -442,15 +418,6 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x4, 0x87, 0xff, 0x4, 0x1, 0x30, 0xc1, 0x60,
0x10,
/* U+5BB6 "家" */
0x4, 0x1f, 0xfe, 0x0, 0xbf, 0xe0, 0x80, 0x28,
0x9a, 0xa0, 0x98, 0x66, 0x83, 0x4f, 0x88, 0x6,
0x0,
/* U+5F00 "开" */
0x7f, 0xc2, 0x20, 0x44, 0x8, 0x81, 0x11, 0xff,
0xc4, 0x40, 0x88, 0x21, 0x4, 0x21, 0x4, 0x0,
/* U+5F0F "式" */
0x1, 0x40, 0x24, 0x4, 0x7f, 0xf0, 0x10, 0x2,
0x1f, 0x40, 0x88, 0x10, 0x82, 0x14, 0x79, 0xf0,
@@ -475,11 +442,6 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x11, 0x11, 0x74, 0x15, 0x4, 0x93, 0x11, 0x86,
0x0,
/* U+6062 "恢" */
0x24, 0x4, 0x80, 0xbf, 0xda, 0xa, 0x49, 0x49,
0x6a, 0xb1, 0x54, 0x28, 0x85, 0x28, 0xc9, 0x12,
0x10,
/* U+606F "息" */
0x8, 0xf, 0xf9, 0x1, 0x3f, 0xe4, 0x4, 0xff,
0x90, 0x13, 0xfe, 0x4, 0xa, 0x49, 0x42, 0xc7,
@@ -490,16 +452,31 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x1, 0x7, 0xff, 0x4, 0x0, 0x80, 0x10, 0xe,
0x0,
/* U+6296 "抖" */
0x20, 0x44, 0x48, 0x85, 0x7c, 0x22, 0x24, 0x42,
0x8c, 0x17, 0x3, 0x27, 0xc4, 0x8, 0x81, 0x30,
0x20,
/* U+62A4 "护" */
0x21, 0x4, 0x10, 0x9f, 0xfa, 0x12, 0x42, 0x48,
0x4d, 0xff, 0x21, 0x24, 0x4, 0x80, 0x90, 0x34,
0x0,
/* U+62A5 "报" */
0x20, 0x4, 0xfc, 0x90, 0xfa, 0x12, 0x4c, 0x48,
0xd, 0xff, 0x29, 0x25, 0x44, 0x90, 0x95, 0x33,
0x10,
/* U+636E "据" */
0x20, 0x4, 0xfc, 0x90, 0xfb, 0xf2, 0x48, 0x4f,
0xcd, 0x27, 0x3f, 0x26, 0x25, 0x44, 0xaf, 0xb9,
0x10,
/* U+63A7 "控" */
0x42, 0x8, 0x21, 0x3f, 0xf4, 0x14, 0x28, 0x88,
0x9a, 0xe, 0x3e, 0x41, 0x8, 0x21, 0x4, 0x67,
0xf0,
/* U+64CD "操" */
0x27, 0xc4, 0x88, 0x9f, 0x78, 0x2, 0xee, 0x55,
0x4f, 0xbf, 0x8, 0x2f, 0xe4, 0x70, 0x95, 0x34,
@@ -515,6 +492,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xa5, 0x51, 0xa, 0xf9, 0x49, 0x10, 0xc5, 0x65,
0x10,
/* U+6574 "整" */
0x11, 0x1f, 0xbd, 0x59, 0x3e, 0xa3, 0x88, 0xaa,
0xa4, 0x8f, 0xff, 0x4, 0x4, 0xf8, 0x90, 0x7f,
0xf0,
/* U+65E0 "无" */
0x7f, 0xc0, 0x80, 0x10, 0x2, 0xf, 0xfe, 0x14,
0x2, 0x80, 0x90, 0x22, 0x28, 0x46, 0x7, 0x80,
@@ -524,14 +506,10 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xa4, 0x94, 0x8a, 0x91, 0x5e, 0xa, 0x41, 0x0,
0xe0,
/* U+663E "显" */
0x7f, 0xc8, 0x9, 0xff, 0x20, 0x27, 0xfc, 0x22,
0x24, 0x4a, 0x8a, 0x31, 0x82, 0x23, 0xff, 0x80,
/* U+672C "本" */
0x4, 0x0, 0x83, 0xff, 0x82, 0x0, 0xe0, 0x2a,
0x5, 0x41, 0x24, 0x44, 0x57, 0xf4, 0x10, 0x2,
0x0,
/* U+677F "板" */
0x20, 0x64, 0xf0, 0x90, 0x7a, 0x2, 0x7e, 0xea,
0x5b, 0x4d, 0x2a, 0x25, 0x44, 0x90, 0xa5, 0x19,
0x10,
/* U+67E5 "查" */
0x4, 0x1f, 0xfc, 0x54, 0x12, 0x4c, 0x46, 0x7f,
@@ -548,6 +526,10 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x9d, 0x15, 0x3e, 0x21, 0x5, 0xfc, 0x8a, 0x16,
0x30,
/* U+6B7B "死" */
0xff, 0xe4, 0x40, 0x88, 0x1d, 0x14, 0xa5, 0x55,
0x4, 0xc0, 0x90, 0x22, 0x28, 0x46, 0x7, 0x80,
/* U+6CE2 "波" */
0x1, 0x10, 0x21, 0x7f, 0x88, 0x91, 0x11, 0x3f,
0x95, 0x10, 0xa2, 0x12, 0x8a, 0x22, 0x4a, 0x16,
@@ -563,15 +545,15 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x5a, 0xa9, 0x55, 0x2a, 0xa9, 0x6, 0x50, 0x91,
0x30,
/* U+7248 "版" */
0x10, 0x6a, 0xf1, 0x50, 0x2a, 0x7, 0xfe, 0x8a,
0x51, 0x4b, 0xaa, 0x55, 0x4a, 0x91, 0x65, 0x49,
0x10,
/* U+6E05 "清" */
0x2, 0x17, 0xfd, 0x8, 0xf, 0xe0, 0x21, 0x7f,
0xd4, 0x10, 0xfe, 0x10, 0x4b, 0xfa, 0x41, 0x8,
0x60,
/* U+7535 "电" */
0x8, 0x1, 0x3, 0xfe, 0x44, 0x48, 0x89, 0xff,
0x22, 0x24, 0x44, 0xff, 0x91, 0x4, 0x20, 0x83,
0xf0,
/* U+70B9 "点" */
0x4, 0x0, 0xfc, 0x10, 0x2, 0x7, 0xfc, 0x80,
0x90, 0x12, 0x2, 0x7f, 0xc0, 0x1, 0x49, 0x44,
0x90,
/* U+754C "界" */
0x7f, 0xc8, 0x89, 0xff, 0x22, 0x27, 0xfc, 0x8,
@@ -587,20 +569,16 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xc8, 0x13, 0xfe, 0xa0, 0x47, 0xf8, 0x81, 0x1f,
0xe0,
/* U+793A "示" */
0x3f, 0x80, 0x0, 0x0, 0x7f, 0xf0, 0x40, 0x8,
0x9, 0x21, 0x22, 0x44, 0x30, 0x84, 0x30, 0x0,
/* U+7C7B "类" */
0x4, 0x4, 0x90, 0x54, 0x7f, 0xf1, 0x50, 0x49,
0x10, 0x10, 0x20, 0xff, 0xe1, 0x40, 0xc6, 0x60,
0x30,
/* U+7CFB "系" */
0x3, 0xcf, 0x80, 0x20, 0x8, 0x43, 0xf0, 0xc,
0x6, 0x13, 0xff, 0x4, 0x24, 0x91, 0x11, 0x46,
0x10,
/* U+7EDC "络" */
0x22, 0x4, 0x79, 0x11, 0x4d, 0x4e, 0x10, 0x45,
0x13, 0x1f, 0xbe, 0x4, 0x46, 0x8b, 0x1f, 0x2,
0x20,
/* U+7EF4 "维" */
0x22, 0x84, 0x49, 0x1f, 0xca, 0x4e, 0xc8, 0x4f,
0xd1, 0x27, 0xa4, 0x7, 0xe6, 0x93, 0x12, 0x3,
@@ -620,10 +598,10 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x8, 0x8, 0x3f, 0xf0, 0x18, 0xf, 0xfe, 0x3,
0x1, 0xff, 0xc0, 0x60, 0x3f, 0xf0,
/* U+8282 "节" */
0x11, 0x2, 0x23, 0xff, 0x88, 0x80, 0x1, 0xff,
0x82, 0x10, 0x42, 0x8, 0x41, 0x18, 0x20, 0x4,
0x0,
/* U+884C "行" */
0x10, 0x4, 0xf9, 0x0, 0x48, 0x2, 0xfe, 0xc1,
0x28, 0x21, 0x4, 0x20, 0x84, 0x10, 0x82, 0x11,
0xc0,
/* U+88C5 "装" */
0x91, 0xa, 0x20, 0x7f, 0x98, 0x85, 0x11, 0x2f,
@@ -635,11 +613,6 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x82, 0x8f, 0xff, 0x0, 0xf, 0xf8, 0x82, 0x1f,
0xc0,
/* U+8BA4 "认" */
0x41, 0x4, 0x20, 0x4, 0x0, 0x8e, 0x10, 0x42,
0x8, 0xa1, 0x14, 0x2a, 0x86, 0x88, 0x91, 0x4,
0x10,
/* U+8BAF "讯" */
0x40, 0x5, 0xf0, 0x12, 0x2, 0x4e, 0x48, 0x5f,
0x9, 0x21, 0x24, 0x24, 0x86, 0x94, 0x91, 0x82,
@@ -660,14 +633,14 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x53, 0xea, 0x45, 0x48, 0xad, 0xf5, 0x20, 0x80,
0x60,
/* U+8C03 "调" */
0x8f, 0xe9, 0x4, 0x24, 0x85, 0xdc, 0x92, 0x9f,
0xd2, 0xa, 0x5d, 0x4a, 0xad, 0x75, 0x20, 0x88,
0x70,
/* U+8F6F "软" */
0x22, 0x4, 0x43, 0xcf, 0xa2, 0x16, 0x45, 0x52,
0x3c, 0x41, 0x8, 0x32, 0x9c, 0x50, 0x91, 0x14,
0x10,
/* U+8FD4 "返" */
0x40, 0xe5, 0xe0, 0x20, 0x7, 0xfe, 0x82, 0x54,
0x4a, 0x51, 0x44, 0x29, 0x46, 0x45, 0x40, 0x47,
/* U+8FD0 "运" */
0x40, 0x4, 0xf8, 0x0, 0x0, 0xe, 0xfe, 0x42,
0x8, 0x81, 0x22, 0x2f, 0xe4, 0x5, 0x40, 0x47,
0xf0,
/* U+901A "通" */
@@ -680,44 +653,35 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x17, 0xfa, 0x8, 0x49, 0x29, 0xfd, 0x0, 0x5f,
0xf0,
/* U+90E8 "部" */
0x10, 0xf, 0xbc, 0x4, 0xa2, 0x92, 0x95, 0xfe,
0x80, 0x4b, 0xe9, 0x45, 0x28, 0xb9, 0xf4, 0x22,
0x80,
/* U+914D "配" */
0xfe, 0xe5, 0x7, 0xf8, 0xd5, 0x1a, 0xaf, 0x8d,
0x20, 0xa7, 0xf4, 0x82, 0xbf, 0xd6, 0x9, 0x80,
/* U+91CF "量" */
0x7f, 0xc8, 0x89, 0x9, 0x7f, 0xf4, 0x44, 0xff,
0x91, 0x13, 0xfe, 0x4, 0xf, 0xf8, 0x10, 0x7f,
0xf0,
/* U+95F4 "间" */
0x9f, 0xd0, 0x10, 0x6, 0xfd, 0xa1, 0x68, 0x5b,
0xf6, 0x85, 0xa1, 0x6f, 0xd8, 0x6, 0x7,
/* U+949F "钟" */
0x40, 0x88, 0x11, 0xe2, 0x41, 0xf7, 0xaa, 0x45,
0x48, 0xaf, 0xdf, 0x22, 0xa5, 0x10, 0xc2, 0x10,
0x40,
/* U+96F6 "零" */
0x7f, 0xc0, 0x83, 0xff, 0xda, 0xd0, 0x40, 0xeb,
0x82, 0x81, 0xac, 0xdf, 0x60, 0x40, 0x70, 0x1,
/* U+9632 "防" */
0x2, 0x1e, 0x22, 0x7f, 0xc9, 0xa, 0x21, 0x47,
0xe4, 0x8c, 0x91, 0x92, 0x3c, 0x86, 0x10, 0xc4,
0x60,
/* U+9664 "除" */
0x1, 0x1c, 0x52, 0x91, 0x54, 0x1c, 0x7d, 0x82,
0x2b, 0xfd, 0x8, 0xa5, 0x59, 0x26, 0x44, 0xc1,
0x80,
/* U+9762 "面" */
0xff, 0xe0, 0x80, 0x20, 0x3f, 0xf4, 0x92, 0x9e,
0x52, 0x4a, 0x79, 0x49, 0x2f, 0xfd, 0x0, 0x80,
/* U+9ED8 "默" */
0xfc, 0x92, 0x9b, 0x72, 0xca, 0x4f, 0xde, 0x21,
0x3f, 0x20, 0x84, 0x1d, 0x5c, 0x29, 0x55, 0x55,
0x10,
/* U+FF1A ":" */
0xf0, 0xf0,
/* U+25B6 "▶"(来源:Segoe UI Symbol;宋体无此字形) */
0x80, 0xe0, 0xf0, 0xfc, 0xff, 0xfe, 0xf8, 0xf0,
0xc0, 0x0
0x80, 0xe0, 0xf0, 0xfc, 0xff, 0xfe, 0xf8, 0xf0, 0xc0, 0x00
};
@@ -831,83 +795,75 @@ static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
{.bitmap_index = 587, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 604, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 621, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 638, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 654, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 671, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 688, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 705, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 722, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 739, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 756, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 772, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 788, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 805, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 821, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 838, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 855, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 872, .adv_w = 192, .box_w = 10, .box_h = 11, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 886, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 903, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 920, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 937, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 954, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 971, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 987, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1004, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1021, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1038, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1052, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1069, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1086, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1103, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1120, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1137, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1154, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1171, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1188, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1205, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1221, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1238, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1254, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1271, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1288, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1305, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1322, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1339, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1356, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1373, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1390, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1407, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1424, .adv_w = 192, .box_w = 10, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1439, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1456, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1472, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1489, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1506, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1523, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1540, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1557, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1571, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1588, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1605, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1622, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1639, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1656, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1673, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1690, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1707, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1724, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1741, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1758, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1775, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1792, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1808, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1825, .adv_w = 192, .box_w = 10, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1840, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1857, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1873, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1890, .adv_w = 192, .box_w = 2, .box_h = 6, .ofs_x = 2, .ofs_y = 0},
{.bitmap_index = 1892, .adv_w = 165, .box_w = 8, .box_h = 10, .ofs_x = 1, .ofs_y = -1} /* U+25B6 ▶ */
{.bitmap_index = 638, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 655, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 672, .adv_w = 192, .box_w = 10, .box_h = 11, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 686, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 702, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 719, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 735, .adv_w = 192, .box_w = 9, .box_h = 10, .ofs_x = 1, .ofs_y = -1},
{.bitmap_index = 747, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 764, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 781, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 798, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 815, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 832, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 849, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 866, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 883, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 900, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 917, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 931, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 948, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 965, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 982, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 999, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1016, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1033, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1050, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1067, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1084, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1101, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1118, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1135, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1151, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1168, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1185, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1202, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1219, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1236, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1252, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1269, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1286, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1303, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1320, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1337, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1354, .adv_w = 192, .box_w = 10, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1369, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1386, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1403, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1420, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1437, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1454, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1471, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1485, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1502, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1519, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1536, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1553, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1570, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1587, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1604, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1621, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1638, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1655, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1672, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1689, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1706, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1723, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1740, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1756, .adv_w = 192, .box_w = 2, .box_h = 6, .ofs_x = 2, .ofs_y = 0},
{.bitmap_index = 1758, .adv_w = 165, .box_w = 8, .box_h = 10, .ofs_x = 1, .ofs_y = -1} /* U+25B6 */
};
/*---------------------
@@ -915,17 +871,16 @@ static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
*--------------------*/
static const uint16_t unicode_list_1[] = {
0x0, 0x32, 0x82, 0x9b, 0xed, 0x144, 0x153, 0x1d8,
0x233, 0x33a, 0x35c, 0x35f, 0x3f1, 0x42d, 0x444, 0x49f,
0x579, 0x582, 0x5b9, 0x5c3, 0x5cf, 0x5ee, 0x641, 0x8d5,
0xb04, 0xd4e, 0xd91, 0xd95, 0xdad, 0x10f7, 0x1106, 0x1131,
0x1149, 0x114a, 0x114c, 0x1259, 0x1266, 0x1442, 0x149b, 0x1565,
0x16c4, 0x173c, 0x1767, 0x17d7, 0x17ed, 0x1835, 0x1923, 0x19dc,
0x1ab7, 0x1c18, 0x1ed9, 0x1f38, 0x1f42, 0x243f, 0x272c, 0x2743,
0x28eb, 0x2902, 0x2b31, 0x2ef2, 0x30d3, 0x30eb, 0x3148, 0x3165,
0x33e1, 0x3479, 0x3abc, 0x3d5d, 0x3d9b, 0x3da6, 0x3da7, 0x3db5,
0x3dd9, 0x3dfa, 0x41cb, 0x4211, 0x425c, 0x42df, 0x4344, 0x43c6,
0x47eb, 0x48ed, 0x4959, 0x50cf, 0xb111
0x0, 0x32, 0x82, 0x9b, 0x153, 0x1d4, 0x1d8, 0x233,
0x42d, 0x444, 0x49f, 0x531, 0x582, 0x5b9, 0x5c3, 0x5da,
0x626, 0x641, 0x982, 0xb04, 0xd4e, 0xd91, 0xd95, 0x1106,
0x1131, 0x1149, 0x114a, 0x114c, 0x1266, 0x1442, 0x148d, 0x149b,
0x149c, 0x1565, 0x159e, 0x16c4, 0x173c, 0x1767, 0x176b, 0x17d7,
0x17ed, 0x1976, 0x19dc, 0x1ab7, 0x1c18, 0x1d72, 0x1ed9, 0x1f38,
0x1f42, 0x1ffc, 0x22b0, 0x2743, 0x28eb, 0x2902, 0x2e72, 0x2ef2,
0x30eb, 0x3148, 0x3165, 0x33e1, 0x3a43, 0x3abc, 0x3d5d, 0x3da6,
0x3da7, 0x3db5, 0x3dd9, 0x4166, 0x41c7, 0x4211, 0x425c, 0x43c6,
0x4696, 0x4829, 0x485b, 0x4959, 0xb111
};
/*Collect the unicode lists and glyph_id offsets*/
@@ -937,10 +892,10 @@ static const lv_font_fmt_txt_cmap_t cmaps[] =
},
{
.range_start = 19977, .range_length = 45330, .glyph_id_start = 96,
.unicode_list = unicode_list_1, .glyph_id_ofs_list = NULL, .list_length = 85, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
.unicode_list = unicode_list_1, .glyph_id_ofs_list = NULL, .list_length = 77, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
},
{
.range_start = 9654, .range_length = 1, .glyph_id_start = 181,
.range_start = 9654, .range_length = 1, .glyph_id_start = 173,
.unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY
}
};
+116 -556
View File
@@ -1,449 +1,129 @@
/*
* 模块名称:UI Menu (菜单页面构建)
* 模块功能:基于 LVGL 基元控件(页面导航)复现 DTU-HMI 树形菜单,支持方向键导航与返回
* 适用平台:STM32F407ZGTx
* 模块名称:UI Menu (菜单导航逻辑层)
* 模块功能:g_menu_tab 父子索引表驱动的横向级联菜单导航——持有导航状态
* (路径/选中/深度/滚动,共 17 B)与键语义(↑↓移选、→/ENTER 进、←/ESC 退),
* 单向驱动显示层 ui_menu_view 建/毁/刷新级联框;本层不持有任何 LVGL 对象
* 适用平台:STM32F407ZGTx / PC 模拟器(两端共用)
* 作者:王建锋
* 创建日期:2026-08-29
* 修改记录:
* 2026-08-29 王建锋 创建初始版本
* 2026-09-02 王建锋 弃用已废弃的 lv_menu 控件,改为基元控件页面导航
* 2026-09-02 王建锋 修复选中行文字不可见(白底白字):行改用 label 承载,选中态样式直接覆盖文字色
* 2026-09-03 王建锋 主界面按设计稿重构:顶部开口浅 V 装饰 + 状态行 + 左对齐带框 7 行列表(选中行整行反色、无箭头,其余行带 ▶);
* 根页条目收敛为一级 7 项(排除"厂家设置"子树);重命名 定值参数→参数定值、遥测量→三遥设置、通讯设置→通讯参数
* 2026-09-02 王建锋 修复选中行文字不可见(白底白字)
* 2026-09-03 王建锋 主界面按设计稿重构(根页 7 项/装饰/状态行)
* 2026-09-04 王建锋 重构为"横向级联框"模型(菜单页面设计.md):拆出显示层
* ui_menu_view 与数据层 ui_menu_tab;本层只留导航状态机
*/
#include "ui_menu.h"
#include "ui_menu_view.h"
#include "ui_menu_tab.h"
#include <stdint.h>
#include "lvgl.h"
#define DBG_TAG "[UI_MENU]"
#include "dbg_log.h"
/* 菜单节点表(简化自 DTU-HMI menuTab):层级 + 名称 */
typedef struct {
uint8_t level; /* 0=根页面, 1..3=子层次 */
const char *name;
} ui_menu_node_t;
static const ui_menu_node_t g_menu_tab[] = {
{0, "主界面"},
{1, "装置信息"},
{1, "实时数据"},
{2, "交流量"},
{2, "直流量"},
{2, "开入量"},
{1, "参数定值"},
{2, "定值"},
{3, "定值"},
{3, "遥调"},
{3, "零压"},
{3, "电压"},
{2, "查看"},
{3, "定值"},
{3, "遥调"},
{3, "零压"},
{3, "电压"},
{1, "三遥设置"},
{2, "测量值"},
{2, "遥调系数"},
{2, "遥测系数"},
{2, "双字节"},
{2, "装置遥调"},
{1, "装置维护"},
{2, "时间设置"},
{2, "强制复归"},
{2, "手动录波"},
{2, "自动录波"},
{1, "通讯参数"},
{2, "通讯参数"},
{2, "网络配置"},
{2, "SNTP设置"},
{1, "记录查询"},
{2, "SOE记录"},
{2, "事故记录"},
{2, "操作记录"},
{2, "告警记录"},
{2, "变位告警"},
{2, "遥信记录"},
{2, "自检记录"},
{2, "定值记录"},
{2, "录波记录"},
{0, "厂家设置"},
{1, "元件设置"},
{1, "恢复默认"},
{2, "全部恢复"},
{2, "默认参数"},
{2, "默认定值"},
{2, "默认系数"},
{2, "电压"},
{2, "元件参数"},
{1, "版本显示"},
{1, "装置遥调"},
{2, "测量遥调"},
{2, "装置遥调"},
{2, "直流遥调"},
{2, "开出遥调"},
{2, "装置记录"},
{2, "告警记录"},
{2, "遥调记录"},
{2, "信号复归"},
{1, "版本信息"},
};
#define MENU_NODE_COUNT (sizeof(g_menu_tab) / sizeof(g_menu_tab[0]))
#define MENU_NODE_ROOT 0xFFFF /* 虚拟根节点(根页面) */
#define MENU_PAGE_MAX 4 /* 页面深度上限(根页 + 3 级子页) */
#define MENU_ITEM_MAX 16 /* 单页最大条目数 */
#define MENU_HEADER_H 18 /* 子页页头高度(px) */
/* 主界面(根页)布局(设备像素,依据设计稿 mockup 实测) */
#define MENU_ROW_H 15 /* 行高 */
#define MENU_ROW_TXT_TOP 1 /* 行内文字垂直居中偏移((15-13)/2,字体行高 13) */
#define MENU_ROW_PAD_L 8 /* 行内文字左留白 */
#define MENU_ROW_ARROW_X 58 /* 行内箭头 x(紧跟 4 字文本之后) */
#define MENU_ROOT_BOX_X 0 /* 列表框左(左对齐,贴左缘) */
#define MENU_ROOT_BOX_Y 44 /* 列表框顶 */
#define MENU_ROOT_BOX_W 99 /* 列表框宽 */
#define MENU_ROOT_BOX_H 107 /* 列表框高(7 行 × 15 + 上下边框 2) */
#define MENU_ROOT_STATUS_X 17 /* 状态行左 */
#define MENU_ROOT_STATUS_Y 20 /* 状态行顶 */
#define MENU_DECO_LEG_W 3 /* 顶部"浅 V"两腿线宽(1bpp 下斜线边缘易被阈值吃掉,需比水平线粗才接近原图) */
#define MENU_DECO_HORIZ_W 2 /* 顶部"浅 V"水平线宽(≈原图 ~2px) */
/*
* 页面:持久容器 + 按导航动态建毁的条目
* 容器常驻对象树(焦点对象不可删除,避免输入设备悬空指针),
* 条目按钮进入页面时创建、返回时销毁,内存占用 = 当前路径
*
* 每条目 = 行容器(整行背景)+ 文本 label + 可选箭头 label:
* - 行容器承载整行反色高亮(选中态白底)
* - 文本/箭头为子 label(绝对定位);选中态文字色须显式置黑
* (主题对 label 直接给白字,父级继承不可靠,见 2026-09-02 记录)
* 导航状态:唯一"必须持久"的导航历史(共 17 B,见设计 §7.2);
* 条目列表/框几何均可由 g_menu_tab + 本状态随时重算,不落 RAM
*/
typedef struct {
lv_obj_t *cont; /* 页面容器(持久) */
lv_obj_t *row[MENU_ITEM_MAX]; /* 行容器(动态) */
lv_obj_t *row_text[MENU_ITEM_MAX]; /* 行文本 label */
lv_obj_t *row_arrow[MENU_ITEM_MAX]; /* 行箭头 label(NULL=无) */
uint16_t item_node[MENU_ITEM_MAX]; /* 各条目对应 g_menu_tab 下标 */
uint16_t node; /* 本页展示节点(MENU_NODE_ROOT=虚拟根) */
uint8_t item_cnt; /* 条目数 */
uint8_t sel; /* 当前选中条目下标 */
uint8_t depth; /* 页面深度(0=根页) */
} ui_menu_page_t;
static ui_menu_page_t s_pages[MENU_PAGE_MAX];
static ui_menu_page_t *s_cur = NULL; /* 当前显示页面 */
static lv_obj_t *s_header = NULL; /* 子页页头容器(返回按钮 + 标题);根页隐藏 */
static lv_obj_t *s_back_btn = NULL; /* 页头返回按钮 */
static lv_obj_t *s_title = NULL; /* 页头标题 */
static lv_obj_t *s_root_deco = NULL; /* 主界面顶部装饰(开口浅 V) */
static lv_obj_t *s_root_status = NULL; /* 主界面状态行 */
static lv_obj_t *s_root_box = NULL; /* 主界面列表框(1px 边框) */
/* 样式(静态,一次性初始化) */
static lv_style_t s_style_flat; /* 通用:透明、无边框/圆角/内边距 */
static lv_style_t s_style_selected; /* 选中态:整行反色(白底) */
static lv_style_t s_style_back; /* 返回按钮:细边框、小内边距 */
static lv_style_t s_style_scrollbar; /* 滚动条:白色、4px 宽 */
static lv_style_t s_style_root_box; /* 主界面列表框:透明底 + 1px 白边框 */
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(d + 1u);
}
/*
* 函数功能:关闭最右框(弹层)
* 入口参数:无
* 返回值:无
* 限定条件:无
* 函数说明: 1. flat 用于剥离主题给对象附加的卡片样式(边框/圆角/内边距)
* 2. selected 为选中条目整行反色高亮(1bpp:白底;文字色在刷新时显式置黑)
* 3. 样式后加优先,可覆盖主题在创建时附加的样式
* 限定条件:s_depth > 0
* 函数说明: 1. 销毁最右框行对象(既有屏上框不动、无需重建)
* 2. 焦点回父框;父层选中/滚动原样保留(选中不跳)
*/
static void menu_styles_init(void)
static void menu_back(void)
{
static bool inited = false;
if (inited) {
return;
}
inited = true;
lv_style_init(&s_style_flat);
lv_style_set_bg_opa(&s_style_flat, LV_OPA_TRANSP);
lv_style_set_border_width(&s_style_flat, 0);
lv_style_set_radius(&s_style_flat, 0);
lv_style_set_pad_all(&s_style_flat, 0);
lv_style_set_pad_gap(&s_style_flat, 0);
lv_style_init(&s_style_selected);
lv_style_set_bg_opa(&s_style_selected, LV_OPA_COVER);
lv_style_set_bg_color(&s_style_selected, lv_color_white());
lv_style_set_text_color(&s_style_selected, lv_color_black());
lv_style_init(&s_style_back);
lv_style_set_bg_opa(&s_style_back, LV_OPA_TRANSP);
lv_style_set_border_width(&s_style_back, 1);
lv_style_set_border_color(&s_style_back, lv_color_white());
lv_style_set_radius(&s_style_back, 0);
lv_style_set_pad_all(&s_style_back, 1);
lv_style_init(&s_style_scrollbar);
lv_style_set_bg_opa(&s_style_scrollbar, LV_OPA_COVER);
lv_style_set_bg_color(&s_style_scrollbar, lv_color_white());
lv_style_set_width(&s_style_scrollbar, 4);
lv_style_init(&s_style_root_box);
lv_style_set_bg_opa(&s_style_root_box, LV_OPA_TRANSP);
lv_style_set_border_width(&s_style_root_box, 1);
lv_style_set_border_color(&s_style_root_box, lv_color_white());
lv_style_set_radius(&s_style_root_box, 0);
lv_style_set_pad_all(&s_style_root_box, 0);
/* mono 主题 card 样式自带 pad_gap=PAD_DEF(4),须显式清零,否则行间距被撑大导致溢出 */
lv_style_set_pad_gap(&s_style_root_box, 0);
ui_menu_view_frame_destroy(s_depth);
s_depth--;
ui_menu_view_focus(s_depth);
}
/*
* 函数功能:向页面添加一个条目(行容器 + 文本 + 可选箭头)
* 入口参数:page - 页面结构指针 ui_menu_page_t *
* parent - 条目父容器 lv_obj_t * 根页=s_root_box,子页=page->cont
* node - g_menu_tab 节点下标 uint16_t
* has_arrow - 是否创建箭头 label bool
* 返回值:无
* 限定条件:page->item_cnt < MENU_ITEM_MAX
* 函数说明:1. 行容器整行宽、高 MENU_ROW_H,承载选中反色高亮
* 2. 文本 label 左留白 MENU_ROW_PAD_L;箭头 label 位于 MENU_ROW_ARROW_X
* 3. 文本/箭头子 label 加 IGNORE_LAYOUT 做行内绝对定位
*/
static void menu_page_add_item(ui_menu_page_t *page, lv_obj_t *parent,
uint16_t node, bool has_arrow)
{
if (page->item_cnt >= MENU_ITEM_MAX) {
return;
}
uint8_t idx = page->item_cnt;
lv_obj_t *row = lv_obj_create(parent);
if (row == NULL) {
return;
}
lv_obj_add_style(row, &s_style_flat, 0);
lv_obj_add_style(row, &s_style_selected, LV_STATE_CHECKED);
lv_obj_set_width(row, lv_pct(100));
lv_obj_set_height(row, MENU_ROW_H);
lv_obj_t *lbl = lv_label_create(row);
if (lbl == NULL) {
lv_obj_delete(row);
return;
}
lv_obj_add_style(lbl, &s_style_flat, 0);
lv_obj_add_flag(lbl, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(lbl, MENU_ROW_PAD_L, MENU_ROW_TXT_TOP);
lv_label_set_text_static(lbl, g_menu_tab[node].name);
lv_obj_t *ar = NULL;
if (has_arrow) {
ar = lv_label_create(row);
if (ar != NULL) {
lv_obj_add_style(ar, &s_style_flat, 0);
lv_obj_add_flag(ar, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(ar, MENU_ROW_ARROW_X, MENU_ROW_TXT_TOP);
lv_label_set_text_static(ar, "▶");
}
}
page->row[idx] = row;
page->row_text[idx] = lbl;
page->row_arrow[idx] = ar;
page->item_node[idx] = node;
page->item_cnt++;
}
/* 前向声明(menu_page_build 先于其定义调用) */
static void menu_page_refresh_sel(ui_menu_page_t *page);
/*
* 函数功能:构建页面条目列表
* 入口参数:page - 页面结构指针 ui_menu_page_t *
* node - 展示节点 uint16_t MENU_NODE_ROOT 表示虚拟根
* 返回值:无
* 限定条件:根页时 s_root_box 已创建
* 函数说明:1. 根页:一级节点 7 项(至第二个根"厂家设置"前截断),带箭头,父容器=s_root_box
* 2. 子页:node+1 起 level 为 (level+1) 的连续段,无箭头,父容器=page->cont
* 3. 先清空父容器旧条目再重建,默认选中首项
*/
static void menu_page_build(ui_menu_page_t *page, uint16_t node)
{
bool is_root = (node == MENU_NODE_ROOT);
lv_obj_t *parent = is_root ? s_root_box : page->cont;
bool has_arrow = is_root;
lv_obj_clean(parent);
page->node = node;
page->item_cnt = 0;
page->sel = 0;
if (is_root) {
for (uint32_t i = 1; i < MENU_NODE_COUNT; i++) {
if (g_menu_tab[i].level == 0) {
break; /* 抵达第二个根(厂家设置),主界面仅取其之前的一级项 */
}
if (g_menu_tab[i].level == 1) {
menu_page_add_item(page, parent, (uint16_t)i, has_arrow);
}
}
}
else {
uint8_t lvl = (uint8_t)(g_menu_tab[node].level + 1);
for (uint32_t i = node + 1; i < MENU_NODE_COUNT; i++) {
if (g_menu_tab[i].level != lvl) {
break;
}
menu_page_add_item(page, parent, (uint16_t)i, has_arrow);
}
}
if (page->item_cnt > 0) {
menu_page_refresh_sel(page);
}
}
/*
* 函数功能:更新选中高亮、文字色与箭头显隐
* 入口参数:page - 页面结构指针 ui_menu_page_t *
* 返回值:无
* 限定条件:page->item_cnt > 0
* 函数说明:1. 选中行整行反色(白底),文字/箭头显式置黑并隐藏箭头
* 2. 非选中行透明底,文字/箭头置白并显示箭头
* 3. 滚动使选中条目进入可视区
*/
static void menu_page_refresh_sel(ui_menu_page_t *page)
{
if (page->item_cnt == 0) {
return;
}
for (uint8_t i = 0; i < page->item_cnt; i++) {
bool sel = (i == page->sel);
if (sel) {
lv_obj_add_state(page->row[i], LV_STATE_CHECKED);
}
else {
lv_obj_clear_state(page->row[i], LV_STATE_CHECKED);
}
lv_obj_set_style_text_color(page->row_text[i],
sel ? lv_color_black() : lv_color_white(), 0);
if (page->row_arrow[i] != NULL) {
if (sel) {
lv_obj_add_flag(page->row_arrow[i], LV_OBJ_FLAG_HIDDEN);
}
else {
lv_obj_remove_flag(page->row_arrow[i], LV_OBJ_FLAG_HIDDEN);
}
lv_obj_set_style_text_color(page->row_arrow[i],
sel ? lv_color_black() : lv_color_white(), 0);
}
}
lv_obj_scroll_to_view(page->row[page->sel], LV_ANIM_OFF);
}
/*
* 函数功能:切换显示页面
* 入口参数:page - 目标页面 ui_menu_page_t *
* 函数功能:按键语义分发(移/进/退)
* 入口参数:depth - 焦点所在层深度 uint8_t
* key - LVGL 键值 lv_key_t
* 返回值:无
* 限定条件:ui_menu_init() 已完成
* 函数说明:1. 隐藏旧页、显示新页(仅切换 HIDDEN 标志)
* 2. 页头仅子页显示;根页隐藏页头(让位顶部装饰 + 状态行,内容区撑满 160)
* 3. 焦点移至新页容器(按键输入入口)
* 函数说明: 1. UP/DOWN:移选(首尾环绕);选中越出可见区由显示层跟随滚动
* 2. ENTER/RIGHT:进子框(叶子 → 空白终点框)
* 3. ESC/LEFT:关最右框(根框不响应)
* 4. 空框(终点页 0 条目)只响应返回
*/
static void menu_show_page(ui_menu_page_t *page)
static void menu_key_handle(uint8_t depth, lv_key_t key)
{
if (s_cur != NULL && s_cur != page) {
lv_obj_add_flag(s_cur->cont, LV_OBJ_FLAG_HIDDEN);
}
s_cur = page;
lv_obj_clear_flag(page->cont, LV_OBJ_FLAG_HIDDEN);
if (page->depth == 0) {
lv_obj_add_flag(s_header, LV_OBJ_FLAG_HIDDEN);
}
else {
lv_obj_remove_flag(s_header, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text_static(s_title, g_menu_tab[page->node].name);
}
lv_group_focus_obj(page->cont);
}
/*
* 函数功能:打开选中条目的子页面
* 入口参数:page - 当前页面 ui_menu_page_t *
* 返回值:无
* 限定条件:page->item_cnt > 0
* 函数说明:1. 在下一深度页面容器上重建条目并显示
* 2. 叶子节点(无子节点)打开空白占位页
* 3. 深度保护:超过 MENU_PAGE_MAX 不再下钻
*/
static void menu_open_selected(ui_menu_page_t *page)
{
if (page->item_cnt == 0) {
return;
}
if (page->depth + 1 >= MENU_PAGE_MAX) {
return;
}
ui_menu_page_t *child = &s_pages[page->depth + 1];
menu_page_build(child, page->item_node[page->sel]);
menu_show_page(child);
}
/*
* 函数功能:返回上级页面
* 入口参数:page - 当前页面 ui_menu_page_t *
* 返回值:无
* 限定条件:page->depth > 0
* 函数说明:1. 销毁本页条目释放 LVGL 池内存(容器保留)
* 2. 显示父页并恢复焦点(父页选中状态保持不变)
*/
static void menu_go_back(ui_menu_page_t *page)
{
if (page->depth == 0) {
return;
}
lv_obj_clean(page->cont);
menu_show_page(&s_pages[page->depth - 1]);
}
/*
* 函数功能:页面容器按键事件处理
* 入口参数:e - 事件指针 lv_event_t *
* 返回值:无
* 限定条件:容器已加入默认输入组并获焦
* 函数说明:1. UP/DOWN:移动选中(环绕)
* 2. ENTER/RIGHT:打开选中条目子页
* 3. ESC/LEFT:返回上级页面
*/
static void menu_page_key_cb(lv_event_t *e)
{
ui_menu_page_t *page = (ui_menu_page_t *)lv_event_get_user_data(e);
lv_key_t key = (lv_key_t)lv_event_get_key(e);
uint8_t kids[MENU_ITEM_MAX];
uint8_t cnt = ui_menu_tab_children(s_path[depth], kids, MENU_ITEM_MAX);
switch (key) {
case LV_KEY_UP:
if (page->item_cnt > 0) {
page->sel = (uint8_t)((page->sel + page->item_cnt - 1) % page->item_cnt);
menu_page_refresh_sel(page);
if (cnt > 0u) {
s_sel[depth] = (uint8_t)((s_sel[depth] + (uint8_t)cnt - 1u) % cnt);
menu_refresh_layer(depth);
}
break;
case LV_KEY_DOWN:
if (page->item_cnt > 0) {
page->sel = (uint8_t)((page->sel + 1) % page->item_cnt);
menu_page_refresh_sel(page);
if (cnt > 0u) {
s_sel[depth] = (uint8_t)((s_sel[depth] + 1u) % cnt);
menu_refresh_layer(depth);
}
break;
case LV_KEY_ENTER:
case LV_KEY_RIGHT:
menu_open_selected(page);
if (cnt > 0u && depth + 1u < MENU_PAGE_MAX) {
menu_open(kids[s_sel[depth]]);
}
break;
case LV_KEY_ESC:
case LV_KEY_LEFT:
menu_go_back(page);
if (depth > 0u) {
menu_back();
}
break;
default:
break;
@@ -451,56 +131,45 @@ static void menu_page_key_cb(lv_event_t *e)
}
/*
* 函数功能:返回按钮点击回调(预留触摸输入)
* 函数功能:页面容器按键事件入口(每层容器各挂一个,user_data=层深度)
* 入口参数:e - 事件指针 lv_event_t *
* 返回值:无
* 限定条件:ui_menu_init() 已完成
* 函数说明:返回上级页面;按键输入由页面容器 ESC/LEFT 处理
* 限定条件:容器已加入默认输入组并获焦
* 函数说明:键值经默认输入组送达焦点容器;统一分发到 menu_key_handle
*/
static void menu_back_btn_cb(lv_event_t *e)
static void menu_key_cb(lv_event_t *e)
{
(void)e;
if (s_cur != NULL) {
menu_go_back(s_cur);
uint8_t depth = (uint8_t)(uintptr_t)lv_event_get_user_data(e);
if (depth < MENU_PAGE_MAX) {
menu_key_handle(depth, (lv_key_t)lv_event_get_key(e));
}
}
/*
* 函数功能:应用主界面顶部"浅 V"装饰线段的公共样式
* 入口参数:line - lv_line 对象;width - 线宽(px)
* 返回值:无
* 限定条件:line 已创建且已设点集
* 函数说明:绝对定位于 (0,0)、忽略布局、白色实线、指定线宽
*/
static void deco_line_apply(lv_obj_t * line, int32_t width)
{
lv_obj_add_flag(line, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(line, 0, 0);
lv_obj_set_size(line, 160, 20);
lv_obj_set_style_line_width(line, width, 0);
lv_obj_set_style_line_color(line, lv_color_white(), 0);
lv_obj_set_style_line_opa(line, LV_OPA_COVER, 0);
}
/*
* 函数功能:构建菜单(基元控件页面导航)
* 函数功能:构建菜单(横向级联框)
* 入口参数:无
* 返回值:0 - 成功,其他 - 失败(对象创建失败)
* 返回值:0 - 成功,其他 - 失败(表自检失败/对象创建失败)
* 限定条件:ui_port_init()/sim_port_init() 已成功调用,活动屏幕存在
* 函数说明: 1. 子页页头(返回按钮 + 标题)+ 内容区,4 个页面容器(每深度一个,持久)
* 2. 主界面(根页):顶部开口浅 V 装饰 + 状态行 + 左对齐带框 7 行列表
* 3. 页面按需构建:ENTER 时建、返回时毁,内存 = 当前路径
* 4. 页面容器加入默认输入组,按键由容器统一处理
* 函数说明: 1. 数据表自检(parent 下标均有效,捕获表内笔误)
* 2. 重置导航状态 → 初始化显示层
* 3. 各层容器加入默认输入组并挂键回调(按键由逻辑层统一处理)
* 4. 建根框(主界面 node 0 子项)并置焦点
* 5. 构建前后监控 LVGL 池内存并打印
*/
int ui_menu_init(void)
{
lv_obj_t *scr = lv_screen_active();
if (scr == NULL) {
if (!ui_menu_tab_valid()) {
DBG_ERROR("menu table: INVALID (parent index out of range)");
return -1;
}
menu_styles_init();
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;
lv_mem_monitor_t mon;
lv_mem_monitor(&mon);
@@ -508,132 +177,23 @@ int ui_menu_init(void)
(unsigned)mon.total_size,
(unsigned)mon.free_size, (unsigned)mon.free_biggest_size);
/* 根容器:铺满屏幕,纵向布局 */
lv_display_t *disp = lv_display_get_default();
lv_obj_t *menu = lv_obj_create(scr);
if (menu == NULL) {
if (ui_menu_view_init() != 0) {
return -1;
}
lv_obj_add_style(menu, &s_style_flat, 0);
lv_obj_set_size(menu, lv_display_get_horizontal_resolution(disp),
lv_display_get_vertical_resolution(disp));
lv_obj_set_flex_flow(menu, LV_FLEX_FLOW_COLUMN);
/* 子页页头:返回按钮 + 标题(根页隐藏) */
s_header = lv_obj_create(menu);
if (s_header == NULL) {
return -1;
}
lv_obj_add_style(s_header, &s_style_flat, 0);
lv_obj_set_width(s_header, lv_pct(100));
lv_obj_set_height(s_header, MENU_HEADER_H);
lv_obj_set_flex_flow(s_header, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(s_header, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(s_header, 4, 0);
s_back_btn = lv_button_create(s_header);
if (s_back_btn == NULL) {
return -1;
}
lv_obj_add_style(s_back_btn, &s_style_back, 0);
lv_obj_set_size(s_back_btn, 28, 16);
lv_obj_set_flex_flow(s_back_btn, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(s_back_btn, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_t *back_lbl = lv_label_create(s_back_btn);
if (back_lbl == NULL) {
return -1;
}
lv_obj_add_style(back_lbl, &s_style_flat, 0);
lv_label_set_text_static(back_lbl, "返回");
lv_obj_add_event_cb(s_back_btn, menu_back_btn_cb, LV_EVENT_CLICKED, NULL);
s_title = lv_label_create(s_header);
if (s_title == NULL) {
return -1;
}
lv_obj_add_style(s_title, &s_style_flat, 0);
lv_label_set_text_static(s_title, "主界面");
lv_obj_add_flag(s_header, LV_OBJ_FLAG_HIDDEN); /* 默认根页:隐藏页头 */
/* 内容区:占满剩余高度(根页隐藏页头时撑满 160) */
lv_obj_t *content = lv_obj_create(menu);
if (content == NULL) {
return -1;
}
lv_obj_add_style(content, &s_style_flat, 0);
lv_obj_set_width(content, lv_pct(100));
lv_obj_set_flex_grow(content, 1);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
/* 页面容器:每深度一个,持久存在(条目按导航建毁) */
for (uint8_t i = 0; i < MENU_PAGE_MAX; i++) {
s_pages[i].cont = lv_obj_create(content);
if (s_pages[i].cont == NULL) {
/* 键接线:各层容器加入默认输入组;按键一律由逻辑层处理(显示层纯渲染) */
for (uint8_t d = 0u; d < MENU_PAGE_MAX; d++) {
lv_obj_t *cont = ui_menu_view_container(d);
if (cont == NULL) {
return -1;
}
lv_obj_add_style(s_pages[i].cont, &s_style_flat, 0);
lv_obj_set_size(s_pages[i].cont, lv_pct(100), lv_pct(100));
lv_obj_add_flag(s_pages[i].cont, LV_OBJ_FLAG_CLICK_FOCUSABLE);
lv_group_add_obj(lv_group_get_default(), s_pages[i].cont);
lv_obj_add_event_cb(s_pages[i].cont, menu_page_key_cb, LV_EVENT_KEY, &s_pages[i]);
s_pages[i].depth = i;
s_pages[i].node = MENU_NODE_ROOT;
if (i > 0) {
/* 子页容器:纵向堆叠条目 + 自动滚动条 */
lv_obj_set_flex_flow(s_pages[i].cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(s_pages[i].cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START,
LV_FLEX_ALIGN_START);
lv_obj_set_scrollbar_mode(s_pages[i].cont, LV_SCROLLBAR_MODE_AUTO);
lv_obj_add_style(s_pages[i].cont, &s_style_scrollbar, LV_PART_SCROLLBAR);
lv_obj_add_flag(s_pages[i].cont, LV_OBJ_FLAG_HIDDEN);
}
lv_group_add_obj(lv_group_get_default(), cont);
lv_obj_add_event_cb(cont, menu_key_cb, LV_EVENT_KEY, (void *)(uintptr_t)d);
}
/* 主界面(根页):顶部"开口浅 V"装饰 + 状态行 + 列表框(绝对定位于 s_pages[0].cont)
* 拆 3 段:两腿加粗(1bpp 下斜线边缘易被阈值吃掉,需比水平线粗才接近原图 ~4px),水平线保持细线 */
{
static const lv_point_precise_t g_deco_leg_l[] = { {7, 7}, {16, 17} };
static const lv_point_precise_t g_deco_hoz[] = { {16, 17}, {145, 17} };
static const lv_point_precise_t g_deco_leg_r[] = { {158, 7}, {145, 17} };
lv_obj_t * leg_l = lv_line_create(s_pages[0].cont);
lv_obj_t * leg_r = lv_line_create(s_pages[0].cont);
s_root_deco = lv_line_create(s_pages[0].cont);
if (leg_l == NULL || leg_r == NULL || s_root_deco == NULL) {
return -1;
}
lv_line_set_points(leg_l, g_deco_leg_l, 2);
lv_line_set_points(leg_r, g_deco_leg_r, 2);
lv_line_set_points(s_root_deco, g_deco_hoz, 2);
deco_line_apply(leg_l, MENU_DECO_LEG_W);
deco_line_apply(leg_r, MENU_DECO_LEG_W);
deco_line_apply(s_root_deco, MENU_DECO_HORIZ_W);
}
s_root_status = lv_label_create(s_pages[0].cont);
if (s_root_status == NULL) {
return -1;
}
lv_obj_add_style(s_root_status, &s_style_flat, 0);
lv_obj_add_flag(s_root_status, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(s_root_status, MENU_ROOT_STATUS_X, MENU_ROOT_STATUS_Y);
lv_label_set_text_static(s_root_status, "当前模式 : 无模式");
s_root_box = lv_obj_create(s_pages[0].cont);
if (s_root_box == NULL) {
return -1;
}
lv_obj_add_style(s_root_box, &s_style_root_box, 0);
lv_obj_add_flag(s_root_box, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(s_root_box, MENU_ROOT_BOX_X, MENU_ROOT_BOX_Y);
lv_obj_set_size(s_root_box, MENU_ROOT_BOX_W, MENU_ROOT_BOX_H);
lv_obj_set_flex_flow(s_root_box, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(s_root_box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START,
LV_FLEX_ALIGN_START);
/* 构建根页并显示 */
menu_page_build(&s_pages[0], MENU_NODE_ROOT);
menu_show_page(&s_pages[0]);
/* 根框:主界面(node 0)子项;建一次、持久 */
ui_menu_view_frame_build(0u, 0u, 0u, 0u);
ui_menu_view_focus(0u);
lv_mem_monitor(&mon);
DBG_INFO("menu build done: total=%u free=%u big=%u used=%u",
+14 -9
View File
@@ -1,12 +1,16 @@
/*
* 模块名称:UI Menu (菜单页面构建)
* 模块功能:基于 LVGL 基元控件(页面导航)复现 DTU-HMI 树形菜单,支持方向键导航与返回
* 适用平台:STM32F407ZGTx
* 模块名称:UI Menu (菜单导航逻辑层)
* 模块功能:g_menu_tab 父子索引表驱动的横向级联菜单导航——持有导航状态
* (路径/选中/深度/滚动,共 17 B)与键语义(↑↓移选、→/ENTER 进、←/ESC 退),
* 单向驱动显示层 ui_menu_view 建/毁/刷新级联框;本层不持有任何 LVGL 对象
* 适用平台:STM32F407ZGTx / PC 模拟器(两端共用)
* 作者:王建锋
* 创建日期:2026-08-29
* 修改记录:
* 2026-08-29 王建锋 创建初始版本
* 2026-09-02 王建锋 弃用已废弃的 lv_menu 控件,改为基元控件页面导航
* 2026-09-04 王建锋 重构为"横向级联框"模型(菜单页面设计.md):拆出显示层
* ui_menu_view 与数据层 ui_menu_tab;本层只留导航状态机
*/
#ifndef UI_MENU_H
#define UI_MENU_H
@@ -16,13 +20,14 @@ extern "C" {
#endif
/*
* 函数功能:构建菜单(基元控件页面导航:页头返回按钮 + 页面切换)
* 函数功能:构建菜单(横向级联框:右贴靠 + 自适应宽 + 顶/底对齐)
* 入口参数:无
* 返回值:0 - 成功,其他 - 失败(对象创建失败)
* 限定条件:ui_port_init() 已成功调用,活动屏幕存在
* 函数说明:1. 依据紧凑节点表按层级组织页面,ENTER 进入子页、ESC/LEFT 返回
* 2. 页面容器加入默认输入组,按键由当前页容器统一处理
* 3. 页面按需构建(进入时建、返回时毁),叶子节点为空白占位页
* 返回值:0 - 成功,其他 - 失败(菜单表自检失败或对象创建失败)
* 限定条件:ui_port_init()(或模拟器 sim_port_init)已成功调用,活动屏幕存在
* 函数说明: 1. 数据表自检(parent 下标均有效,捕获表内笔误)
* 2. 初始化显示层(样式/4 层持久容器/根框装饰与框体)
* 3. 各层容器加入默认输入组,按键由逻辑层统一分发(显示层纯渲染)
* 4. 构建根框(主界面 node 0 的子项)并置焦点
*/
int ui_menu_init(void);
+119
View File
@@ -0,0 +1,119 @@
/* 本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 自动生成,请勿手改。
* 修改菜单:编辑 ui_menu_tree.txt 后重跑 `python tools/menu_gen/gen_menu.py`。
*/
#include "ui_menu_tab.h"
const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {
{ 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 */
};
/*
* 函数功能:取 node 的直接子项(按表序),最多 max 个
* 入口参数:node - 节点下标 uint8_t
* out - 子节点下标输出缓冲 uint8_t *
* max - 缓冲容量 uint8_t
* 返回值:实际子项个数 uint8_t
* 限定条件:node < UI_MENU_TAB_COUNT
* 函数说明:子项 = 表中所有 parent == 本下标的行(表序即显示序);建页核心查询
*/
uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max)
{
uint8_t n = 0u;
for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT && n < max; i++) {
if (g_menu_tab[i].parent == node) {
out[n++] = i;
}
}
return n;
}
/*
* 函数功能:判断 node 有无子项
* 入口参数:node - 节点下标 uint8_t
* 返回值:有子项返回 true(=菜单、行带▶);无子项返回 false(=叶子)
* 限定条件:node < UI_MENU_TAB_COUNT
* 函数说明:菜单/叶子由"是否有子项"自动推导,无单独类型字段
*/
bool ui_menu_tab_has_children(uint8_t node)
{
for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT; i++) {
if (g_menu_tab[i].parent == node) {
return true;
}
}
return false;
}
/*
* 函数功能:取节点显示名
* 入口参数:node - 节点下标 uint8_t
* 返回值:显示文本(Flash 字面量) const char *
* 限定条件:node < UI_MENU_TAB_COUNT
* 函数说明:行文本一律经此取静态字符串,不 strdup
*/
const char *ui_menu_tab_name(uint8_t node)
{
return g_menu_tab[node].name;
}
/*
* 函数功能:菜单表启动自检
* 入口参数:无
* 返回值:true - 表有效;false - 存在越界 parent(表内笔误)
* 限定条件:无
* 函数说明:每个非根节点的 parent 必须为 MENU_ROOT 或有效下标;在 ui_menu_init 调用
*/
bool ui_menu_tab_valid(void)
{
for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT; i++) {
if (i == 0u) {
continue; /* 根 */
}
uint8_t p = g_menu_tab[i].parent;
if (p != MENU_ROOT && p >= UI_MENU_TAB_COUNT) {
return false;
}
}
return true;
}
+34
View File
@@ -0,0 +1,34 @@
/* 本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 自动生成,请勿手改。
* 修改菜单:编辑 ui_menu_tree.txt 后重跑 `python tools/menu_gen/gen_menu.py`。
*/
#ifndef UI_MENU_TAB_H
#define UI_MENU_TAB_H
#include <stdint.h>
#include <stdbool.h>
#define MENU_ROOT 0xFFu /* 根哨兵:仅根节点(主界面)的 parent 取此值;8 位,上限 255 项 */
/* 菜单节点:子项 = parent 等于本下标的所有行(按表序);有子项=菜单,无=叶子 */
typedef struct {
uint8_t parent; /* 父节点下标(0..254);根为 MENU_ROOT(0xFF) */
const char *name; /* 显示文本(字面量,存 Flash) */
} ui_menu_node_t;
#define UI_MENU_TAB_COUNT 42
/* 护栏:8 位索引上限 255 项(哨兵 0xFF 占用 255);超界则编译失败(C99 写法,兼容 ARMCC v5) */
typedef char menu_tab_count_check[(UI_MENU_TAB_COUNT <= 255) ? 1 : -1];
extern const ui_menu_node_t g_menu_tab[];
/*
* 表查询 API(导航逻辑层 ui_menu.c 与显示层 ui_menu_view.c 共用):
* 实现随表生成,改菜单(ui_menu_tree.txt)调用方零代码改动。
*/
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 下标均有效 */
#endif
+490
View File
@@ -0,0 +1,490 @@
/*
* 模块名称:UI Menu View (菜单显示模型渲染层)
* 模块功能:以 LVGL 基元控件实现《菜单页面设计.md》§四⑧"横向级联子框"显示模型:
* 右贴靠(子框左缘=父框右缘、共享边框列)+ 框宽内容自适应 +
* 顶对齐/底对齐上移;持有全部 LVGL 对象(样式/4 层容器/框/行/根框装饰),
* 不持有任何导航状态——状态由逻辑层 ui_menu.c 经本接口驱动
* 适用平台:STM32F407ZGTx / PC 模拟器(两端共用)
* 作者:王建锋
* 创建日期:2026-09-04
* 修改记录:
* 2026-09-04 王建锋 创建:自旧 ui_menu.c 单页模型拆分出显示层(高内聚低耦合)
*/
#include "ui_menu_view.h"
#include "ui_menu_tab.h"
#include "lvgl.h"
#include <string.h>
#define DBG_TAG "[UI_MENU_VIEW]"
#include "dbg_log.h"
/*
* 布局常量(设备像素;参考图 02~07 实测 + 字体度量速查推导):
* 汉字步进 12px/字、▶ 步进 10px、行高 15(7 行×15+2 边框=107,与参考图根框一致);
* 行内留白取最小值 2px——最深 4 框级联(根 66 + 42 + 42 + 空框 6 - 3 共享边框)
* = 153px,须 < 屏宽 160
*/
#define MENU_ROW_H 15 /* 行高 */
#define MENU_ROW_TXT_TOP 1 /* 行内文字垂直居中偏移((15-13)/2,字体行高 13) */
#define MENU_ROW_PAD_L 2 /* 行内文字左留白(紧留白,保证级联总宽 < 160) */
#define MENU_ROW_PAD_R 2 /* 行内右侧留白 */
#define MENU_ARROW_GAP 2 /* 行内 文本→箭头 间距 */
#define MENU_FRAME_BORDER 1 /* 框边框宽(子框与父框共享该边框列) */
#define MENU_ROOT_BOX_X 0 /* 根框左(左对齐,贴左缘) */
#define MENU_ROOT_BOX_Y 44 /* 根框顶 */
#define MENU_ROOT_STATUS_X 17 /* 状态行左 */
#define MENU_ROOT_STATUS_Y 20 /* 状态行顶 */
#define MENU_DECO_LEG_W 3 /* 顶部"浅 V"两腿线宽(1bpp 下斜线边缘易被阈值吃掉,需加粗) */
#define MENU_DECO_HORIZ_W 2 /* 顶部"浅 V"水平线宽 */
/*
* 框几何缓存:位置/尺寸是"现算"的(不落导航状态),但子框对齐要用父框刚算出的值,
* 故按层缓存一份;sel 为最近一次应用的高亮(子框垂直对齐依据),由逻辑层经
* build/refresh 保持同步
*/
typedef struct {
bool built; /* 框已建(框体 + 行对象存在) */
uint8_t sel; /* 最近应用的选中行 */
uint8_t item_cnt; /* 行数 */
int32_t x, y; /* 框左上角(含边框) */
int32_t w, h; /* 框外廓尺寸 */
} menu_frame_t;
static lv_obj_t *s_cont[MENU_PAGE_MAX]; /* 4 层持久容器(每深度一个;焦点/键入口,永不删) */
static lv_obj_t *s_frame_box[MENU_PAGE_MAX]; /* 框体:d=0 持久(根框);d>=1 进入时建、返回时毁 */
static lv_obj_t *s_row[MENU_PAGE_MAX][MENU_ITEM_MAX]; /* 行容器(整行反色高亮的载体) */
static lv_obj_t *s_row_text[MENU_PAGE_MAX][MENU_ITEM_MAX]; /* 行文本 label */
static lv_obj_t *s_row_arrow[MENU_PAGE_MAX][MENU_ITEM_MAX]; /* 行箭头 label(叶子=NULL) */
static menu_frame_t s_frame[MENU_PAGE_MAX];
static lv_obj_t *s_root_deco = NULL; /* 根框装饰:顶部"浅 V"水平段(两腿为临时对象,样式同源) */
static lv_obj_t *s_root_status = NULL; /* 根框状态行 */
/* 样式(静态,一次性初始化,常驻) */
static lv_style_t s_style_flat; /* 通用:透明、无边框/圆角/内边距(剥离主题 card) */
static lv_style_t s_style_selected; /* 选中态:整行反色(白底) */
static lv_style_t s_style_frame; /* 框体:透明底 + 1px 白边框 */
static lv_style_t s_style_scrollbar; /* 滚动条:白色、4px 宽 */
/*
* 函数功能:初始化显示层样式(静态样式,仅执行一次)
* 入口参数:无
* 返回值:无
* 限定条件:无
* 函数说明: 1. flat 剥离主题给对象附加的卡片样式(边框/圆角/内边距)
* 2. frame 为所有级联框(含根框)的 1px 白边框
* 3. selected 为选中行整行反色(1bpp 白底);文字色在刷新时显式置黑
*/
static void view_styles_init(void)
{
static bool inited = false;
if (inited) {
return;
}
inited = true;
lv_style_init(&s_style_flat);
lv_style_set_bg_opa(&s_style_flat, LV_OPA_TRANSP);
lv_style_set_border_width(&s_style_flat, 0);
lv_style_set_radius(&s_style_flat, 0);
lv_style_set_pad_all(&s_style_flat, 0);
lv_style_set_pad_gap(&s_style_flat, 0);
lv_style_init(&s_style_selected);
lv_style_set_bg_opa(&s_style_selected, LV_OPA_COVER);
lv_style_set_bg_color(&s_style_selected, lv_color_white());
lv_style_set_text_color(&s_style_selected, lv_color_black());
lv_style_init(&s_style_frame);
lv_style_set_bg_opa(&s_style_frame, LV_OPA_TRANSP);
lv_style_set_border_width(&s_style_frame, MENU_FRAME_BORDER);
lv_style_set_border_color(&s_style_frame, lv_color_white());
lv_style_set_radius(&s_style_frame, 0);
lv_style_set_pad_all(&s_style_frame, 0);
lv_style_set_pad_gap(&s_style_frame, 0);
lv_style_init(&s_style_scrollbar);
lv_style_set_bg_opa(&s_style_scrollbar, LV_OPA_COVER);
lv_style_set_bg_color(&s_style_scrollbar, lv_color_white());
lv_style_set_width(&s_style_scrollbar, 4);
}
/*
* 函数功能:计算框外廓宽(内容自适应)
* 入口参数:kids - 子节点下标数组(各行内容) const uint8_t *
* cnt - 条目数 uint8_t
* 返回值:框外廓宽 px int32_t = 最宽行内容 + 左右边框
* 限定条件:LVGL 已初始化(字体度量可用)
* 函数说明:行内容宽 = 左留白 + 文本 + (菜单 ? 间距 + ▶ : 0) + 右留白;
* 文本/▶ 宽取字体实际步进(汉字 12px、▶ 10px);
* 注意本 LVGL 版 lv_text_get_width 的 length=0 表示"不测量",必须传 strlen
*/
static int32_t view_frame_width(const uint8_t *kids, uint8_t cnt)
{
const lv_font_t *font = LV_FONT_DEFAULT;
int32_t w = 0;
for (uint8_t i = 0; i < cnt; i++) {
const char *name = ui_menu_tab_name(kids[i]);
int32_t tw = lv_text_get_width(name, (uint32_t)strlen(name), font, 0);
int32_t cw = MENU_ROW_PAD_L + tw + MENU_ROW_PAD_R;
if (ui_menu_tab_has_children(kids[i])) {
cw += MENU_ARROW_GAP + lv_text_get_width("▶", (uint32_t)strlen("▶"), font, 0);
}
if (cw > w) {
w = cw;
}
}
return w + 2 * MENU_FRAME_BORDER;
}
/*
* 函数功能:在框体上创建一条条目行(行容器 + 文本 + 可选箭头)
* 入口参数:box - 所属框体 lv_obj_t *
* d - 框所在深度 uint8_t
* i - 行下标 uint8_t
* node - 条目节点下标 uint8_t
* has_arrow - 是否创建箭头(=条目有子项) bool
* 返回值:true - 成功;false - 对象创建失败(LVGL 池耗尽,调用方停止建行)
* 限定条件:d < MENU_PAGE_MAX,i < MENU_ITEM_MAX
* 函数说明: 1. 行容器整行宽、高 MENU_ROW_H,承载选中整行反色高亮
* 2. 文本左留白 MENU_ROW_PAD_L;箭头紧跟文本(间距 MENU_ARROW_GAP,自适应)
* 3. 文本/箭头子 label 加 IGNORE_LAYOUT 做行内绝对定位
*/
static bool view_add_row(lv_obj_t *box, uint8_t d, uint8_t i, uint8_t node, bool has_arrow)
{
lv_obj_t *row = lv_obj_create(box);
if (row == NULL) {
return false;
}
lv_obj_add_style(row, &s_style_flat, 0);
lv_obj_add_style(row, &s_style_selected, LV_STATE_CHECKED);
lv_obj_set_width(row, lv_pct(100));
lv_obj_set_height(row, MENU_ROW_H);
lv_obj_t *lbl = lv_label_create(row);
if (lbl == NULL) {
lv_obj_delete(row);
return false;
}
lv_obj_add_style(lbl, &s_style_flat, 0);
lv_obj_add_flag(lbl, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(lbl, MENU_ROW_PAD_L, MENU_ROW_TXT_TOP);
lv_label_set_text_static(lbl, ui_menu_tab_name(node));
lv_obj_t *ar = NULL;
if (has_arrow) {
const char *name = ui_menu_tab_name(node);
int32_t tw = lv_text_get_width(name, (uint32_t)strlen(name), LV_FONT_DEFAULT, 0);
ar = lv_label_create(row);
if (ar != NULL) {
lv_obj_add_style(ar, &s_style_flat, 0);
lv_obj_add_flag(ar, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(ar, MENU_ROW_PAD_L + tw + MENU_ARROW_GAP, MENU_ROW_TXT_TOP);
lv_label_set_text_static(ar, "▶");
}
}
s_row[d][i] = row;
s_row_text[d][i] = lbl;
s_row_arrow[d][i] = ar;
return true;
}
/*
* 函数功能:应用指定深度框的选中高亮(整行反色 + 文字/箭头颜色)
* 入口参数:depth - 深度 uint8_t
* sel - 选中行 uint8_t
* 返回值:无
* 限定条件:该层行对象已建
* 函数说明:1. 选中行整行反色(白底黑字),箭头随反色变黑(参考图实测:选中行保留 ▶)
* 2. 非选中行透明底白字、白色箭头
*/
static void view_apply_sel(uint8_t depth, uint8_t sel)
{
menu_frame_t *f = &s_frame[depth];
for (uint8_t i = 0; i < f->item_cnt; i++) {
bool is_sel = (i == sel);
if (is_sel) {
lv_obj_add_state(s_row[depth][i], LV_STATE_CHECKED);
}
else {
lv_obj_clear_state(s_row[depth][i], LV_STATE_CHECKED);
}
lv_obj_set_style_text_color(s_row_text[depth][i],
is_sel ? lv_color_black() : lv_color_white(), 0);
if (s_row_arrow[depth][i] != NULL) {
lv_obj_set_style_text_color(s_row_arrow[depth][i],
is_sel ? lv_color_black() : lv_color_white(), 0);
}
}
}
/*
* 函数功能:配置框体布局(纵向 flex 堆行 + 自动滚动条)
* 入口参数:box - 框体 lv_obj_t *
* 返回值:无
* 限定条件:box 已创建
* 函数说明:行内容超框高时框内滚动(本菜单最深 9 行不触发,机制为更长列表保留)
*/
static void view_box_layout(lv_obj_t *box)
{
lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_scrollbar_mode(box, LV_SCROLLBAR_MODE_AUTO);
lv_obj_add_style(box, &s_style_scrollbar, LV_PART_SCROLLBAR);
}
void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t scroll)
{
menu_frame_t *f = &s_frame[depth];
lv_obj_t *box;
uint8_t kids[MENU_ITEM_MAX];
uint8_t cnt = ui_menu_tab_children(node, kids, MENU_ITEM_MAX);
if (depth == 0u) {
box = s_frame_box[0]; /* 根框框体持久(view_init 已建) */
}
else {
if (f->built) {
ui_menu_view_frame_destroy(depth); /* 防御:重入 = 先毁后建 */
}
const menu_frame_t *p = &s_frame[depth - 1u];
/* X:右贴靠——子框左缘 = 父框右缘(共享边框列、零间隙) */
f->x = p->x + p->w - MENU_FRAME_BORDER;
/* Y:顶对齐父框"选中行"行顶;(顶边+框高) 超屏底 → 上移使本框底边
* 对齐父框"选中行"行底(顶部不钳制,可叠到状态行区);
* 仍超顶(比整屏高)→ 钳制顶部,框高截到可见区、行改框内滚动 */
int32_t row_top = p->y + MENU_FRAME_BORDER + (int32_t)p->sel * MENU_ROW_H;
int32_t h = (int32_t)cnt * MENU_ROW_H + 2 * MENU_FRAME_BORDER;
f->y = row_top;
if (f->y + h > MENU_SCREEN_H) {
f->y = row_top + MENU_ROW_H - h;
}
if (f->y < 0) {
f->y = 0;
}
if (f->y + h > MENU_SCREEN_H) {
h = MENU_SCREEN_H - f->y;
}
f->h = h;
f->w = (cnt > 0u) ? view_frame_width(kids, cnt)
: (2 * MENU_FRAME_BORDER + MENU_ROW_PAD_L + MENU_ROW_PAD_R);
box = lv_obj_create(s_cont[depth]);
if (box == NULL) {
return; /* LVGL 池耗尽:框未建,状态回滚 */
}
lv_obj_add_style(box, &s_style_flat, 0);
lv_obj_add_style(box, &s_style_frame, 0);
lv_obj_add_flag(box, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(box, f->x, f->y);
lv_obj_set_size(box, f->w, f->h);
view_box_layout(box);
s_frame_box[depth] = box;
}
f->built = true;
f->item_cnt = cnt;
f->sel = sel;
/* 行对象:条目现算(= parent==node 过滤),进时建、退时毁 */
lv_obj_clean(box);
for (uint8_t i = 0; i < MENU_ITEM_MAX; i++) {
s_row[depth][i] = NULL;
s_row_text[depth][i] = NULL;
s_row_arrow[depth][i] = NULL;
}
for (uint8_t i = 0; i < cnt; i++) {
if (!view_add_row(box, depth, i, kids[i], ui_menu_tab_has_children(kids[i]))) {
break; /* LVGL 池耗尽:停止建行(池有界,见设计 §7.5) */
}
}
/* 选中高亮 + 滚动位 */
view_apply_sel(depth, sel);
if (scroll > 0u) {
lv_obj_scroll_to_y(box, (int32_t)scroll, LV_ANIM_OFF);
}
else if (cnt > 0u) {
lv_obj_scroll_to_view(s_row[depth][sel], LV_ANIM_OFF);
}
}
void ui_menu_view_frame_destroy(uint8_t depth)
{
menu_frame_t *f = &s_frame[depth];
if (depth == 0u || !f->built) {
return; /* 根框持久不可销毁 */
}
lv_obj_delete(s_frame_box[depth]); /* 框体连行对象整体删除(容器保留) */
s_frame_box[depth] = NULL;
for (uint8_t i = 0; i < MENU_ITEM_MAX; i++) {
s_row[depth][i] = NULL;
s_row_text[depth][i] = NULL;
s_row_arrow[depth][i] = NULL;
}
f->built = false;
f->item_cnt = 0u;
f->sel = 0u;
f->x = 0;
f->y = 0;
f->w = 0;
f->h = 0;
}
void ui_menu_view_frame_refresh(uint8_t depth, uint8_t sel)
{
menu_frame_t *f = &s_frame[depth];
if (!f->built || depth >= MENU_PAGE_MAX || sel >= f->item_cnt) {
return;
}
f->sel = sel;
view_apply_sel(depth, sel);
/* 选中变化只改行状态 + 跟随滚动,不重建对象 */
lv_obj_scroll_to_view(s_row[depth][sel], LV_ANIM_OFF);
}
void ui_menu_view_set_scroll(uint8_t depth, uint16_t scroll)
{
if (depth >= MENU_PAGE_MAX || !s_frame[depth].built || s_frame_box[depth] == NULL) {
return;
}
lv_obj_scroll_to_y(s_frame_box[depth], (int32_t)scroll, LV_ANIM_OFF);
}
uint16_t ui_menu_view_get_scroll(uint8_t depth)
{
if (depth >= MENU_PAGE_MAX || !s_frame[depth].built || s_frame_box[depth] == NULL) {
return 0u;
}
return (uint16_t)lv_obj_get_scroll_y(s_frame_box[depth]);
}
void ui_menu_view_focus(uint8_t depth)
{
if (depth < MENU_PAGE_MAX && s_cont[depth] != NULL) {
lv_group_focus_obj(s_cont[depth]);
}
}
lv_obj_t *ui_menu_view_container(uint8_t depth)
{
return (depth < MENU_PAGE_MAX) ? s_cont[depth] : NULL;
}
/*
* 函数功能:应用主界面顶部"浅 V"装饰线段的公共样式
* 入口参数:line - lv_line 对象;width - 线宽(px)
* 返回值:无
* 限定条件:line 已创建且已设点集
* 函数说明:绝对定位于 (0,0)、忽略布局、白色实线、指定线宽
*/
static void view_deco_line_apply(lv_obj_t *line, int32_t width)
{
lv_obj_add_flag(line, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(line, 0, 0);
lv_obj_set_size(line, MENU_SCREEN_W, 20);
lv_obj_set_style_line_width(line, width, 0);
lv_obj_set_style_line_color(line, lv_color_white(), 0);
lv_obj_set_style_line_opa(line, LV_OPA_COVER, 0);
}
int ui_menu_view_init(void)
{
lv_obj_t *scr = lv_screen_active();
if (scr == NULL) {
return -1;
}
view_styles_init();
lv_display_t *disp = lv_display_get_default();
int32_t sw = lv_display_get_horizontal_resolution(disp);
int32_t sh = lv_display_get_vertical_resolution(disp);
/* 屏幕根容器:铺满屏幕;子对象全部绝对定位(级联框须同屏叠加,不用 flex 堆叠) */
lv_obj_t *menu = lv_obj_create(scr);
if (menu == NULL) {
return -1;
}
lv_obj_add_style(menu, &s_style_flat, 0);
lv_obj_set_size(menu, sw, sh);
/* 4 层持久容器:每深度一个、全屏覆盖、永不删(焦点/键入口,防悬空) */
for (uint8_t d = 0; d < MENU_PAGE_MAX; d++) {
s_cont[d] = lv_obj_create(menu);
if (s_cont[d] == NULL) {
return -1;
}
lv_obj_add_style(s_cont[d], &s_style_flat, 0);
lv_obj_add_flag(s_cont[d], LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(s_cont[d], 0, 0);
lv_obj_set_size(s_cont[d], sw, sh);
lv_obj_add_flag(s_cont[d], LV_OBJ_FLAG_CLICK_FOCUSABLE);
}
/* 根框装饰:顶部"开口浅 V"(两腿加粗、水平线细)+ 状态行(绝对定位于 d=0 容器)
* 1bpp 下斜线边缘易被阈值吃掉,腿须比水平线粗才接近参考图 */
{
static const lv_point_precise_t deco_leg_l[] = { {7, 7}, {16, 17} };
static const lv_point_precise_t deco_hoz[] = { {16, 17}, {145, 17} };
static const lv_point_precise_t deco_leg_r[] = { {158, 7}, {145, 17} };
lv_obj_t *leg_l = lv_line_create(s_cont[0]);
lv_obj_t *leg_r = lv_line_create(s_cont[0]);
s_root_deco = lv_line_create(s_cont[0]);
if (leg_l == NULL || leg_r == NULL || s_root_deco == NULL) {
return -1;
}
lv_line_set_points(leg_l, deco_leg_l, 2);
lv_line_set_points(leg_r, deco_leg_r, 2);
lv_line_set_points(s_root_deco, deco_hoz, 2);
view_deco_line_apply(leg_l, MENU_DECO_LEG_W);
view_deco_line_apply(leg_r, MENU_DECO_LEG_W);
view_deco_line_apply(s_root_deco, MENU_DECO_HORIZ_W);
}
s_root_status = lv_label_create(s_cont[0]);
if (s_root_status == NULL) {
return -1;
}
lv_obj_add_style(s_root_status, &s_style_flat, 0);
lv_obj_add_flag(s_root_status, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(s_root_status, MENU_ROOT_STATUS_X, MENU_ROOT_STATUS_Y);
lv_label_set_text_static(s_root_status, "当前模式 : 无模式");
/* 根框框体:持久;宽 = 内容自适应(主界面 node 0 的子项) */
uint8_t kids[MENU_ITEM_MAX];
uint8_t cnt = ui_menu_tab_children(0u, kids, MENU_ITEM_MAX);
int32_t w = view_frame_width(kids, cnt);
int32_t h = (int32_t)cnt * MENU_ROW_H + 2 * MENU_FRAME_BORDER;
s_frame_box[0] = lv_obj_create(s_cont[0]);
if (s_frame_box[0] == NULL) {
return -1;
}
lv_obj_add_style(s_frame_box[0], &s_style_flat, 0);
lv_obj_add_style(s_frame_box[0], &s_style_frame, 0);
lv_obj_add_flag(s_frame_box[0], LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(s_frame_box[0], MENU_ROOT_BOX_X, MENU_ROOT_BOX_Y);
lv_obj_set_size(s_frame_box[0], w, h);
view_box_layout(s_frame_box[0]);
s_frame[0].built = true; /* 框体就绪;行对象由逻辑层 frame_build(0) 创建 */
s_frame[0].x = MENU_ROOT_BOX_X;
s_frame[0].y = MENU_ROOT_BOX_Y;
s_frame[0].w = w;
s_frame[0].h = h;
DBG_INFO("view init: root frame w=%d h=%d", (int)w, (int)h);
return 0;
}
+109
View File
@@ -0,0 +1,109 @@
/*
* 模块名称:UI Menu View (菜单显示模型渲染层)
* 模块功能:以 LVGL 基元控件实现《菜单页面设计.md》§四⑧"横向级联子框"显示模型:
* 右贴靠(子框左缘=父框右缘、共享边框列)+ 框宽内容自适应 +
* 顶对齐/底对齐上移;持有全部 LVGL 对象(样式/4 层容器/框/行/根框装饰),
* 不持有任何导航状态——状态由逻辑层 ui_menu.c 经本接口驱动
* 适用平台:STM32F407ZGTx / PC 模拟器(两端共用)
* 作者:王建锋
* 创建日期:2026-09-04
* 修改记录:
* 2026-09-04 王建锋 创建:自旧 ui_menu.c 单页模型拆分出显示层(高内聚低耦合)
*/
#ifndef UI_MENU_VIEW_H
#define UI_MENU_VIEW_H
#include <stdint.h>
#include <stdbool.h>
#include "lvgl.h"
#define MENU_PAGE_MAX 4 /* 页面深度上限(根框 + 3 级子框) */
#define MENU_ITEM_MAX 16 /* 单框最大条目数 */
#define MENU_SCREEN_W 160 /* 屏幕宽(px,与 ui_port.h / 模拟器一致) */
#define MENU_SCREEN_H 160 /* 屏幕高(px) */
/*
* 函数功能:初始化显示层(样式 + 4 层持久容器 + 根框装饰 + 根框框体)
* 入口参数:无
* 返回值:0 - 成功,其他 - 失败(对象创建失败)
* 限定条件:LVGL 已初始化且活动屏幕存在(ui_port_init / sim_port_init 已完成)
* 函数说明:1. 4 层容器每深度一个、全屏覆盖、持久不删(焦点/键入口,防悬空)
* 2. 根框装饰(浅 V + 状态行)与根框框体只属根层(d=0)
* 3. 行对象不在此建,由逻辑层调 ui_menu_view_frame_build 按需建
*/
int ui_menu_view_init(void);
/*
* 函数功能:取指定深度的持久容器
* 入口参数:depth - 深度 0..MENU_PAGE_MAX-1 uint8_t
* 返回值:容器对象 lv_obj_t * 越界返回 NULL
* 限定条件:ui_menu_view_init() 已完成
* 函数说明:逻辑层在容器上绑定输入组与键事件(显示层不碰输入)
*/
lv_obj_t *ui_menu_view_container(uint8_t depth);
/*
* 函数功能:构建指定深度的框(几何现算 + 行对象创建 + 选中/滚动应用)
* 入口参数:depth - 深度(0=根框) uint8_t
* node - 本框展示节点(行 = 其子项) uint8_t
* sel - 本框选中行 uint8_t
* scroll - 本框滚动偏移(px,0=顶部) uint16_t
* 返回值:无
* 限定条件:depth>0 时 depth-1 层框已建(父框几何是其对齐依据)
* 函数说明: 1. d=0:根框框体已存在,仅(重)建行对象
* 2. d>0:右贴靠父框右缘;顶边=父框选中行行顶,超屏底则
* 上移使本框底边对齐父框选中行行底,仍超顶则钳制顶部并改框内滚动
* 3. 框宽 = 最宽行(文本 + ▶ + 左右留白)+ 边框;条目现算不落地
*/
void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t scroll);
/*
* 函数功能:销毁指定深度的框(框体连同行对象,容器保留)
* 入口参数:depth - 深度 uint8_t
* 返回值:无
* 限定条件:depth>0(根框持久不可销毁)
* 函数说明:返回(关最右框)时调用,LVGL 池内存随即回落
*/
void ui_menu_view_frame_destroy(uint8_t depth);
/*
* 函数功能:刷新指定深度框的选中高亮
* 入口参数:depth - 深度 uint8_t
* sel - 新选中行 uint8_t
* 返回值:无
* 限定条件:ui_menu_view_frame_build(depth,…) 已完成
* 函数说明:只改行状态与文字色 + 跟随滚动,不重建对象
*/
void ui_menu_view_frame_refresh(uint8_t depth, uint8_t sel);
/*
* 函数功能:应用指定深度框的滚动偏移
* 入口参数:depth - 深度 uint8_t
* scroll - 滚动偏移(px) uint16_t
* 返回值:无
* 限定条件:该层框已建
* 函数说明:行对象重建后按此还原用户滚动位置
*/
void ui_menu_view_set_scroll(uint8_t depth, uint16_t scroll);
/*
* 函数功能:读取指定深度框当前滚动偏移
* 入口参数:depth - 深度 uint8_t
* 返回值:滚动偏移(px) uint16_t 未建框返回 0
* 限定条件:无
* 函数说明:跟随滚动后由逻辑层读回,写入导航状态持久化
*/
uint16_t ui_menu_view_get_scroll(uint8_t depth);
/*
* 函数功能:焦点移至指定深度的容器
* 入口参数:depth - 深度 uint8_t
* 返回值:无
* 限定条件:ui_menu_view_init() 已完成且容器已加入输入组
* 函数说明:焦点=最右框;进/退时由逻辑层调用
*/
void ui_menu_view_focus(uint8_t depth);
#endif /* UI_MENU_VIEW_H */
+10
View File
@@ -3118,6 +3118,16 @@
<FileType>1</FileType>
<FilePath>..\Drivers\BSP\UI\ui_menu.c</FilePath>
</File>
<File>
<FileName>ui_menu_view.c</FileName>
<FileType>1</FileType>
<FilePath>..\Drivers\BSP\UI\ui_menu_view.c</FilePath>
</File>
<File>
<FileName>ui_menu_tab.c</FileName>
<FileType>1</FileType>
<FilePath>..\Drivers\BSP\UI\ui_menu_tab.c</FilePath>
</File>
<File>
<FileName>ui_net_sink.c</FileName>
<FileType>1</FileType>
@@ -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 警 |
+2
View File
@@ -68,6 +68,8 @@ set(SIM_SOURCES
# UI 源文件(与 MCU 端共用,直接编译)
set(UI_SOURCES
${UI_DIR}/ui_menu.c
${UI_DIR}/ui_menu_view.c
${UI_DIR}/ui_menu_tab.c
${UI_DIR}/ui_font_cn_12.c
)
+40 -4
View File
@@ -12,6 +12,11 @@
* lvgl_sim.exe --screenshot shot.bin
* 走与 MCU 端一致的离屏软件渲染(ARGB8888 缓冲 + mono 主题),
* 渲染首帧后将 160x160 像素按 BGRA 顺序写入 shot.bin(102400 字节),随即退出。
*
* 键序截图模式(无窗口 + 注入按键序列后截图,用于级联菜单无头回归):
* lvgl_sim.exe --keys <seq> shot.bin
* seq 字符:u=↑ d=↓ l=← r=→ e=Enter s=Esc(其余字符忽略)
* 每键经 lv_group_send_data 注入(与 keypad 输入设备同路径),驱动 5 帧后取快照
*/
#include <stdlib.h>
@@ -59,11 +64,28 @@ static void shot_flush_cb(lv_display_t * disp, const lv_area_t * area, uint8_t *
}
/*
* @brief 无窗口离屏渲染并导出首帧像素
* @param path 输出文件(写入 160x160 BGRA 原始像素)
* 键序列字符 → LVGL 键值(非键字符返回 0 = 忽略)
*/
static uint32_t sim_map_key_char(char c)
{
switch (c) {
case 'u': return LV_KEY_UP;
case 'd': return LV_KEY_DOWN;
case 'l': return LV_KEY_LEFT;
case 'r': return LV_KEY_RIGHT;
case 'e': return LV_KEY_ENTER;
case 's': return LV_KEY_ESC;
default: return 0;
}
}
/*
* @brief 无窗口离屏渲染(可注入键序列)并导出末帧像素
* @param keys 键序列(u=上 d=下 l=左 r=右 e=确认 s=返回;空 = 不注键)
* @param path 输出文件(写入 160x160 I1 原始像素)
* @return 0 成功,非 0 失败
*/
static int run_screenshot(const char * path)
static int run_headless(const char * keys, const char * path)
{
setvbuf(stdout, NULL, _IONBF, 0);
lv_init();
@@ -96,6 +118,17 @@ static int run_screenshot(const char * path)
return 1;
}
/* 键注入:与 keypad 输入设备同路径(向焦点对象发 LV_EVENT_KEY)。
* 键处理是同步的(状态变更 + 场景更新当场完成),无需驱动帧;
* 无头模式严禁调 lv_timer_handler()/lv_refr_now()——I1+FULL 全屏刷新会进渲染死循环 */
for (const char *p = keys; *p != '\0'; p++) {
uint32_t key = sim_map_key_char(*p);
if (key == 0u) {
continue;
}
lv_group_send_data(g, key);
}
/* 用 snapshot 把整屏渲进独立 I1 draw_buf(避开 I1+FULL 的渲染死循环) */
lv_draw_buf_t *snap = lv_snapshot_take(lv_screen_active(), LV_COLOR_FORMAT_I1);
if (snap == NULL) {
@@ -130,7 +163,10 @@ int main(int argc, char **argv)
/* 截图模式:lvgl_sim.exe --screenshot <path> */
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--screenshot") == 0 && (i + 1) < argc) {
return run_screenshot(argv[i + 1]);
return run_headless("", argv[i + 1]);
}
if (strcmp(argv[i], "--keys") == 0 && (i + 2) < argc) {
return run_headless(argv[i + 1], argv[i + 2]);
}
}
+4 -2
View File
@@ -85,8 +85,10 @@ def main():
chars |= collect(open(f, encoding='utf-8-sig', errors='ignore').read())
s = ''.join(sorted(chars))
out_path = os.path.join(ROOT, 'ui_cn_chars.txt')
with open(out_path, 'w', encoding='utf-8') as fp:
fp.write(s)
# newline='' 关闭行尾转换:恒输出 LF(与 gen_menu.py / make_font.py 一致),
# 避免同一脚本在 Windows 出 CRLF、Linux 出 LF 导致提交产物跨平台 diff
with open(out_path, 'w', encoding='utf-8', newline='') as fp:
fp.write(s + '\n')
print('scanned %d files; unique non-ASCII chars: %d' % (len(SCAN), len(s)))
print('->', out_path)
+24
View File
@@ -110,7 +110,30 @@ def merge_arrow_glyph(font_path):
print('已合并箭头 U+25B6:位图+%d 字节,glyph_id=%d' % (len(ARROW_BITMAP), glyph_count))
def fix_trailing_newline(path):
"""规范化文件末尾为恰好一个 \n(pre-commit end-of-file-fixer 要求)。
lv_font_conv 产物末尾多一个空行(`#endif` 之后 `\n\n`),统一在此收敛为单个换行,
避免每次重生成字体都触发 end-of-file-fixer 拦截提交。
"""
with open(path, 'rb') as fp:
data = fp.read()
body = data
while body.endswith(b'\r\n') or body.endswith(b'\n'):
body = body[:-2] if body.endswith(b'\r\n') else body[:-1]
fixed = body + b'\n'
if fixed != data:
with open(path, 'wb') as fp:
fp.write(fixed)
def main():
# Windows GBK 控制台无法打印 ▶ 等符号:降级为替换字符,避免 UnicodeEncodeError 崩溃
try:
sys.stdout.reconfigure(errors='replace')
except Exception:
pass
ap = argparse.ArgumentParser()
ap.add_argument('--bpp', type=int, default=1, help='字体位深 1/2/3(默认 1)')
ap.add_argument('--size', type=int, default=12, help='字号(默认 12)')
@@ -138,6 +161,7 @@ def main():
if r.returncode != 0:
sys.exit('FAILED rc=%d' % r.returncode)
merge_arrow_glyph(OUT) # 补回宋体缺失的 ▶ 字形(幂等)
fix_trailing_newline(OUT) # 规范化末尾换行(pre-commit end-of-file-fixer)
print('OK %d bytes -> %s' % (os.path.getsize(OUT), OUT))
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成菜单节点表 ui_menu_tab.c / ui_menu_tab.h(扁平"父子索引表",输出到 Drivers/BSP/UI/)。
把"菜单长什么样"声明在缩进文本源 ui_menu_tree.txt(缩进即层级,每级 2 空格,
`#` 开头为注释)。本脚本读源、推导每个节点的 parent 下标、产出 C 表,供 ui_menu.c 消费。
改菜单只需编辑 ui_menu_tree.txt 后重跑本脚本(与字体 ui_cn_chars.txt -> make_font.py 同模式)。
用法(仓库根目录或任意目录均可,脚本自动定位仓库根):
python tools/menu_gen/gen_menu.py
"""
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SRC = os.path.join(ROOT, 'ui_menu_tree.txt')
OUT_H = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_menu_tab.h')
OUT_C = os.path.join(ROOT, 'Drivers', 'BSP', 'UI', 'ui_menu_tab.c')
ROOT_ID = 0xFF # 内部根标记(= MENU_ROOT=0xFF);与 8 位索引一致,映射到 C 的 MENU_ROOT 宏
MAX_DEPTH = 8
MAX_NODES = 255 # uint8_t 索引 + 哨兵 0xFF → 最多 255 个节点(下标 0..254)
def parse_tree(text):
"""把缩进文本解析为 [(parent, name, depth), ...]。
parent 为父节点下标,根节点为 ROOT_ID;子项 = parent 等于本下标的所有行(按表序)。
缩进单元取"最小非零缩进"(每级一个单元),据此把缩进换算成层级。
"""
lines = []
for raw in text.splitlines():
line = raw.rstrip()
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
if '\t' in line:
sys.exit('ERROR: 请用空格缩进(勿用 Tab): ' + repr(raw))
indent = len(line) - len(line.lstrip(' '))
lines.append((indent, stripped))
if not lines:
sys.exit('ERROR: ui_menu_tree.txt 无有效节点')
indents = sorted(set(i for i, _ in lines if i > 0))
unit = indents[0] if indents else 1
for i, name in lines:
if i % unit != 0:
sys.exit('ERROR: 缩进不是 %d 的整数倍: %r (indent=%d)' % (unit, name, i))
nodes = []
stack = [] # [(depth, index), ...] 当前各层的父节点下标
for indent, name in lines:
depth = indent // unit
if depth > MAX_DEPTH:
sys.exit('ERROR: 层级过深(>%d): %r' % (MAX_DEPTH, name))
while stack and stack[-1][0] >= depth:
stack.pop()
parent = stack[-1][1] if stack else ROOT_ID
idx = len(nodes)
nodes.append((parent, name, depth))
stack.append((depth, idx))
roots = [i for i, (_, _, d) in enumerate(nodes) if d == 0]
if len(roots) != 1:
sys.exit('ERROR: 必须有且仅有一个根节点(depth 0),当前 %d 个: %s'
% (len(roots), [nodes[i][1] for i in roots]))
return nodes
def emit(nodes):
n = len(nodes)
menus = sum(1 for i in range(n) if any(nodes[j][0] == i for j in range(n)))
max_depth = max(d for _, _, d in nodes)
h = [
'/* 本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 自动生成,请勿手改。',
' * 修改菜单:编辑 ui_menu_tree.txt 后重跑 `python tools/menu_gen/gen_menu.py`。',
' */',
'#ifndef UI_MENU_TAB_H',
'#define UI_MENU_TAB_H',
'',
'#include <stdint.h>',
'#include <stdbool.h>',
'',
'#define MENU_ROOT 0xFFu /* 根哨兵:仅根节点(主界面)的 parent 取此值;8 位,上限 255 项 */',
'',
'/* 菜单节点:子项 = parent 等于本下标的所有行(按表序);有子项=菜单,无=叶子 */',
'typedef struct {',
' uint8_t parent; /* 父节点下标(0..254);根为 MENU_ROOT(0xFF) */',
' const char *name; /* 显示文本(字面量,存 Flash) */',
'} ui_menu_node_t;',
'',
'#define UI_MENU_TAB_COUNT %d' % n,
'',
'/* 护栏:8 位索引上限 255 项(哨兵 0xFF 占用 255);超界则编译失败(C99 写法,兼容 ARMCC v5) */',
'typedef char menu_tab_count_check[(UI_MENU_TAB_COUNT <= 255) ? 1 : -1];',
'',
'extern const ui_menu_node_t g_menu_tab[];',
'',
'/*',
' * 表查询 API(导航逻辑层 ui_menu.c 与显示层 ui_menu_view.c 共用):',
' * 实现随表生成,改菜单(ui_menu_tree.txt)调用方零代码改动。',
' */',
'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 下标均有效 */',
'',
'#endif',
'',
]
c = [
'/* 本文件由 tools/menu_gen/gen_menu.py 从 ui_menu_tree.txt 自动生成,请勿手改。',
' * 修改菜单:编辑 ui_menu_tree.txt 后重跑 `python tools/menu_gen/gen_menu.py`。',
' */',
'#include "ui_menu_tab.h"',
'',
'const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {',
]
for i, (parent, name, depth) in enumerate(nodes):
pstr = 'MENU_ROOT' if parent == ROOT_ID else '%d' % parent
c.append(' { %8s, "%s" }, /* %2d L%d */' % (pstr, name, i, depth))
c.append('};')
c.append('')
c += [
'/*',
' * 函数功能:取 node 的直接子项(按表序),最多 max 个',
' * 入口参数:node - 节点下标 uint8_t',
' * out - 子节点下标输出缓冲 uint8_t *',
' * max - 缓冲容量 uint8_t',
' * 返回值:实际子项个数 uint8_t',
' * 限定条件:node < UI_MENU_TAB_COUNT',
' * 函数说明:子项 = 表中所有 parent == 本下标的行(表序即显示序);建页核心查询',
' */',
'uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max)',
'{',
' uint8_t n = 0u;',
' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT && n < max; i++) {',
' if (g_menu_tab[i].parent == node) {',
' out[n++] = i;',
' }',
' }',
' return n;',
'}',
'',
'/*',
' * 函数功能:判断 node 有无子项',
' * 入口参数:node - 节点下标 uint8_t',
' * 返回值:有子项返回 true(=菜单、行带▶);无子项返回 false(=叶子)',
' * 限定条件:node < UI_MENU_TAB_COUNT',
' * 函数说明:菜单/叶子由"是否有子项"自动推导,无单独类型字段',
' */',
'bool ui_menu_tab_has_children(uint8_t node)',
'{',
' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT; i++) {',
' if (g_menu_tab[i].parent == node) {',
' return true;',
' }',
' }',
' return false;',
'}',
'',
'/*',
' * 函数功能:取节点显示名',
' * 入口参数:node - 节点下标 uint8_t',
' * 返回值:显示文本(Flash 字面量) const char *',
' * 限定条件:node < UI_MENU_TAB_COUNT',
' * 函数说明:行文本一律经此取静态字符串,不 strdup',
' */',
'const char *ui_menu_tab_name(uint8_t node)',
'{',
' return g_menu_tab[node].name;',
'}',
'',
'/*',
' * 函数功能:菜单表启动自检',
' * 入口参数:无',
' * 返回值:true - 表有效;false - 存在越界 parent(表内笔误)',
' * 限定条件:无',
' * 函数说明:每个非根节点的 parent 必须为 MENU_ROOT 或有效下标;在 ui_menu_init 调用',
' */',
'bool ui_menu_tab_valid(void)',
'{',
' for (uint8_t i = 0u; i < UI_MENU_TAB_COUNT; i++) {',
' if (i == 0u) {',
' continue; /* 根 */',
' }',
' uint8_t p = g_menu_tab[i].parent;',
' if (p != MENU_ROOT && p >= UI_MENU_TAB_COUNT) {',
' return false;',
' }',
' }',
' return true;',
'}',
'',
]
return '\n'.join(h), '\n'.join(c), menus, max_depth
def main():
if not os.path.exists(SRC):
sys.exit('ERROR: 未找到 ui_menu_tree.txt(应在仓库根目录)')
text = open(SRC, encoding='utf-8-sig').read()
nodes = parse_tree(text)
if len(nodes) > MAX_NODES:
sys.exit('ERROR: 节点数 %d 超过 uint8_t 索引上限 %d(哨兵 MENU_ROOT=0xFF 占用 255);请精简菜单或改回 16 位索引' % (len(nodes), MAX_NODES))
header, body, menus, max_depth = emit(nodes)
# Drivers/BSP/UI/* 约定为 UTF-8 带 BOM(同 ui_menu.c);字符串字面量含中文,
# 无 BOM 时 cl.exe(代码页 936) 会按 GBK 误读 UTF-8 字节导致字符串解析错乱。
with open(OUT_H, 'w', encoding='utf-8-sig', newline='') as fp:
fp.write(header)
with open(OUT_C, 'w', encoding='utf-8-sig', newline='') as fp:
fp.write(body)
print('nodes=%d menu=%d leaf=%d max_depth=%d' % (len(nodes), menus, len(nodes) - menus, max_depth))
print('->', os.path.relpath(OUT_H, ROOT))
print('->', os.path.relpath(OUT_C, ROOT))
if __name__ == '__main__':
main()
+1 -1
View File
@@ -1 +1 @@
▶三主事交件位作信值元入全出制前动厂压参双变号告回复字定实家开式强归当录恢息手护据操故数无时显本查检模波流测版电界直看示系络维网置自节装警认讯记设询调返通遥部配量间零面默:
▶三主事交作保信值制前动区压参双口启告型复字定实式强归当录息手抖护报据控操故数整无时板查检模死波流测清点界直看类系维网置自行装警讯记设询软运通遥量钟防除面:
+47
View File
@@ -0,0 +1,47 @@
# 主菜单层级定义(声明式源)
# 规则:缩进即层级,每级 2 空格;`#` 开头为注释;空行忽略。
# 改菜单只需编辑本文件,然后重跑:
# python tools/menu_gen/gen_menu.py
# 产物:Drivers/BSP/UI/ui_menu_tab.c / ui_menu_tab.h
主界面
装置信息
实时数据
交流量
直流量
遥信量
参数定值
整定
参数
定值
控制字
软压板
查看
参数
定值
控制字
软压板
三遥设置
遥测死区
遥测系数
遥信类型
遥信防抖
双点遥信
装置维护
时钟设置
强制复归
手动录波
清除记录
通讯参数
通讯设置
网口设置
SNTP设置
记录查询
SOE记录
事故记录
操作记录
保护告警
保护启动
遥控记录
自检记录
运行记录
运行报告