完成一个主界面开发流程

This commit is contained in:
2026-09-03 23:08:16 +08:00
parent 52d7dc7d4d
commit 89462d8aa1
21 changed files with 2177 additions and 198 deletions
+6
View File
@@ -84,6 +84,12 @@ Lib/lvgl/sconscript
simulator/build/
sim_dbg.log
# LVGL PC Simulator - screenshot outputs (generated, see simulator/i1_to_png.py)
simulator/shot*.png
simulator/shot*.bin
simulator/*.ppm
simulator/__pycache__/
# LVGL PC Simulator - SDL2 SDK (downloaded, see docs/测试说明/LVGL模拟器部署与使用说明.md)
simulator/SDL2/
+1 -1
View File
@@ -153,6 +153,6 @@ cmake --build build --config Debug
- **绝不用 `Get-Content` / `Set-Content` / `cmd` 文本重写含中文的源码文件**。这类"整文件重写"会按系统默认编码重新解码再写回,多字节中文必被 `U+FFFD` 替换且不可逆。
- **改代码只用 Edit 工具**(精确子串替换,不重新编解码整个文件,安全);批量替换用 Edit 的 `replaceAll`。
- 必须脚本级替换时,使用显式且一致的编码(**本工程源码为 UTF-8 无 BOM**,如 Python `open(p, encoding="utf-8")` 同编码回写)——但**最稳的是别碰**,只用 Edit / git。
- 必须脚本级替换时,使用显式且一致的编码(**工程源码为 UTF-8**:多数文件无 BOM,但 UI 相关文件 `Drivers/BSP/UI/*`、`App/task/ui_task.c`、`Lib/lv_conf.h` 为 UTF-8 **带 BOM**——如 Python `open(p, encoding="utf-8"/"utf-8-sig")` 按原样同编码回写,保持 BOM 状态不变)——但**最稳的是别碰**,只用 Edit / git。
- **发现乱码先别急着 `git checkout`**:`git checkout` 会丢弃所有未提交工作,使损失扩大。先评估能否用 Edit 仅改坏掉的那几行。
- 验证是否真损坏用字节级检查(统计 `EF BF BD` 出现次数),不要依赖终端/Read 渲染判断。
+144 -123
View File
@@ -1,7 +1,7 @@
/*******************************************************************************
* Size: 12 px
* Bpp: 1
* Opts: --font C:\Users\16241\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 程序\STM32F4-Base\Drivers\BSP\UI\ui_font_cn_12.c
* 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
******************************************************************************/
#ifdef LV_LVGL_H_INCLUDE_SIMPLE
@@ -308,6 +308,10 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
/* U+007E "~" */
0x40, 0x81, 0x80,
/* U+4E09 "三" */
0x7f, 0xc0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7f,
0x0, 0x0, 0x0, 0x0, 0x0, 0x3, 0xff, 0x80,
/* U+4E3B "主" */
0x8, 0x0, 0x80, 0x0, 0x3f, 0xe0, 0x40, 0x8,
0x1, 0x3, 0xfe, 0x4, 0x0, 0x80, 0x10, 0x7f,
@@ -372,9 +376,9 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x5f, 0x4a, 0xa9, 0x55, 0x2a, 0x85, 0x70, 0x88,
0x70,
/* U+529F "功" */
0x2, 0x0, 0x43, 0xe8, 0x13, 0xf2, 0x22, 0x44,
0x48, 0x89, 0x11, 0x3c, 0x38, 0x84, 0x20, 0x88,
/* U+524D "前" */
0x20, 0x82, 0x23, 0xff, 0x80, 0x7, 0xc4, 0x8a,
0x9f, 0x52, 0x2a, 0x7d, 0x48, 0xa9, 0x11, 0x26,
0x60,
/* U+52A8 "动" */
@@ -382,10 +386,6 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x50, 0x4a, 0x89, 0x89, 0x3f, 0x44, 0x8, 0x82,
0x60,
/* U+5360 "占" */
0x8, 0x2, 0x0, 0x80, 0x3f, 0x8, 0x2, 0xf,
0xfa, 0x2, 0x80, 0xa0, 0x2f, 0xfa, 0x2,
/* U+5382 "厂" */
0x7f, 0xe8, 0x1, 0x0, 0x20, 0x4, 0x0, 0x80,
0x10, 0x2, 0x0, 0x40, 0x8, 0x2, 0x0, 0x0,
@@ -418,6 +418,10 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
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+590D "复" */
0x3f, 0xe8, 0x2, 0xff, 0x10, 0x23, 0xfc, 0x40,
0x8f, 0xf0, 0x80, 0x3f, 0xca, 0x10, 0x3c, 0x38,
@@ -447,6 +451,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
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,
0x10,
/* U+5F3A "强" */
0x7, 0xdc, 0x88, 0x91, 0x13, 0xee, 0x11, 0x1f,
0xe2, 0x4f, 0x49, 0x2f, 0xe4, 0x20, 0x85, 0x67,
@@ -457,6 +466,10 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xe4, 0xc, 0x81, 0x90, 0x24, 0x4, 0x9f, 0xa0,
0x10,
/* U+5F53 "当" */
0x8, 0x44, 0x52, 0x41, 0xf, 0xf8, 0x4, 0x2,
0xff, 0x0, 0x80, 0x7f, 0xe0, 0x10,
/* U+5F55 "录" */
0x7f, 0x80, 0x10, 0xfe, 0x0, 0x4f, 0xfe, 0x8,
0x11, 0x11, 0x74, 0x15, 0x4, 0x93, 0x11, 0x86,
@@ -502,6 +515,10 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xa5, 0x51, 0xa, 0xf9, 0x49, 0x10, 0xc5, 0x65,
0x10,
/* U+65E0 "无" */
0x7f, 0xc0, 0x80, 0x10, 0x2, 0xf, 0xfe, 0x14,
0x2, 0x80, 0x90, 0x22, 0x28, 0x46, 0x7, 0x80,
/* U+65F6 "时" */
0x0, 0x5e, 0xa, 0x41, 0x4b, 0xf9, 0x5, 0xe0,
0xa4, 0x94, 0x8a, 0x91, 0x5e, 0xa, 0x41, 0x0,
@@ -511,11 +528,6 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x7f, 0xc8, 0x9, 0xff, 0x20, 0x27, 0xfc, 0x22,
0x24, 0x4a, 0x8a, 0x31, 0x82, 0x23, 0xff, 0x80,
/* U+672A "未" */
0x4, 0x0, 0x81, 0xff, 0x2, 0x0, 0x41, 0xff,
0xc3, 0x80, 0xa8, 0x24, 0x98, 0x8c, 0x10, 0x2,
0x0,
/* U+672C "本" */
0x4, 0x0, 0x83, 0xff, 0x82, 0x0, 0xe0, 0x2a,
0x5, 0x41, 0x24, 0x44, 0x57, 0xf4, 0x10, 0x2,
@@ -531,6 +543,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x9c, 0x5, 0x12, 0x29, 0x44, 0xa8, 0x92, 0x17,
0xf0,
/* U+6A21 "模" */
0x22, 0x85, 0xfc, 0x8a, 0x7b, 0xe2, 0x44, 0xcf,
0x9d, 0x15, 0x3e, 0x21, 0x5, 0xfc, 0x8a, 0x16,
0x30,
/* U+6CE2 "波" */
0x1, 0x10, 0x21, 0x7f, 0x88, 0x91, 0x11, 0x3f,
0x95, 0x10, 0xa2, 0x12, 0x8a, 0x22, 0x4a, 0x16,
@@ -551,10 +568,6 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x51, 0x4b, 0xaa, 0x55, 0x4a, 0x91, 0x65, 0x49,
0x10,
/* U+73B0 "现" */
0xf7, 0xc4, 0x88, 0x95, 0x12, 0xaf, 0x54, 0x4a,
0x89, 0x51, 0x14, 0x32, 0x98, 0x94, 0x21, 0x80,
/* U+7535 "电" */
0x8, 0x1, 0x3, 0xfe, 0x44, 0x48, 0x89, 0xff,
0x22, 0x24, 0x44, 0xff, 0x91, 0x4, 0x20, 0x83,
@@ -603,11 +616,6 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xf, 0xe1, 0x4, 0x20, 0x84, 0x90, 0x92, 0x7f,
0xf0,
/* U+80FD "能" */
0x22, 0x49, 0x53, 0xec, 0x1, 0x17, 0x9e, 0x90,
0x1e, 0x82, 0x52, 0x7a, 0x89, 0x65, 0x28, 0xac,
0xf0,
/* U+81EA "自" */
0x8, 0x8, 0x3f, 0xf0, 0x18, 0xf, 0xfe, 0x3,
0x1, 0xff, 0xc0, 0x60, 0x3f, 0xf0,
@@ -657,6 +665,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0xd2, 0xa, 0x5d, 0x4a, 0xad, 0x75, 0x20, 0x88,
0x70,
/* U+8FD4 "返" */
0x40, 0xe5, 0xe0, 0x20, 0x7, 0xfe, 0x82, 0x54,
0x4a, 0x51, 0x44, 0x29, 0x46, 0x45, 0x40, 0x47,
0xf0,
/* U+901A "通" */
0x9f, 0x88, 0x90, 0xc, 0xf, 0xed, 0x24, 0xbf,
0x94, 0x92, 0xfe, 0x52, 0x4a, 0x59, 0x0, 0x5f,
@@ -699,11 +712,12 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
0x3f, 0x20, 0x84, 0x1d, 0x5c, 0x29, 0x55, 0x55,
0x10,
/* U+FF08 "(" */
0x29, 0x49, 0x22, 0x44,
/* U+FF1A ":" */
0xf0, 0xf0,
/* U+FF09 ")" */
0x89, 0x12, 0x4a, 0x50
/* U+25B6 "▶"(来源:Segoe UI Symbol;宋体无此字形) */
0x80, 0xe0, 0xf0, 0xfc, 0xff, 0xfe, 0xf8, 0xf0,
0xc0, 0x0
};
@@ -808,89 +822,92 @@ static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
{.bitmap_index = 476, .adv_w = 96, .box_w = 1, .box_h = 13, .ofs_x = 3, .ofs_y = -2},
{.bitmap_index = 478, .adv_w = 96, .box_w = 3, .box_h = 12, .ofs_x = 1, .ofs_y = -1},
{.bitmap_index = 483, .adv_w = 96, .box_w = 6, .box_h = 3, .ofs_x = 0, .ofs_y = 8},
{.bitmap_index = 486, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 503, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 520, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 537, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 554, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 571, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 588, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 605, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 622, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.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 = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 689, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 706, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 723, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 740, .adv_w = 192, .box_w = 10, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 755, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 771, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 787, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 804, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 820, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 837, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 854, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 871, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 888, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 905, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 922, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 939, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 956, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 972, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 989, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1006, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1023, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1040, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1057, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1074, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1091, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1108, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1125, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1142, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1159, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1176, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1192, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1209, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1226, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1243, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1260, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1277, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1294, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1311, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1328, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1344, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1361, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1378, .adv_w = 192, .box_w = 10, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1393, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1410, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1426, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1443, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1460, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1477, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1494, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1511, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1528, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1542, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1559, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1576, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1593, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1610, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1627, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1644, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1661, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1678, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1695, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1712, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1729, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1746, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1762, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1779, .adv_w = 192, .box_w = 10, .box_h = 12, .ofs_x = 1, .ofs_y = -2},
{.bitmap_index = 1794, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1811, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1827, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 1844, .adv_w = 192, .box_w = 3, .box_h = 10, .ofs_x = 6, .ofs_y = -1},
{.bitmap_index = 1848, .adv_w = 192, .box_w = 3, .box_h = 10, .ofs_x = 2, .ofs_y = -1}
{.bitmap_index = 486, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 502, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 519, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 536, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 553, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.bitmap_index = 570, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2},
{.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 ▶ */
};
/*---------------------
@@ -898,17 +915,17 @@ static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
*--------------------*/
static const uint16_t unicode_list_1[] = {
0x0, 0x50, 0x69, 0xbb, 0x112, 0x121, 0x1a6, 0x201,
0x308, 0x32a, 0x32d, 0x3bf, 0x3fb, 0x464, 0x46d, 0x525,
0x547, 0x550, 0x587, 0x591, 0x59d, 0x5bc, 0x60f, 0xad2,
0xd1c, 0xd5f, 0xd63, 0xd7b, 0x10c5, 0x10ff, 0x1117, 0x111a,
0x1227, 0x1234, 0x1410, 0x1469, 0x1533, 0x1692, 0x170a, 0x1735,
0x17bb, 0x1803, 0x18ef, 0x18f1, 0x19aa, 0x1a85, 0x1ea7, 0x1f06,
0x1f10, 0x240d, 0x2575, 0x26fa, 0x2711, 0x28b9, 0x28d0, 0x2aff,
0x2ec0, 0x30a1, 0x30b9, 0x3116, 0x3133, 0x32c2, 0x33af, 0x3447,
0x3a8a, 0x3d2b, 0x3d69, 0x3d74, 0x3d75, 0x3d83, 0x3da7, 0x3dc8,
0x41df, 0x422a, 0x42ad, 0x4312, 0x4394, 0x47b9, 0x48bb, 0x4927,
0x509d, 0xb0cd, 0xb0ce
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
};
/*Collect the unicode lists and glyph_id offsets*/
@@ -919,8 +936,12 @@ static const lv_font_fmt_txt_cmap_t cmaps[] =
.unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY
},
{
.range_start = 20027, .range_length = 45263, .glyph_id_start = 96,
.unicode_list = unicode_list_1, .glyph_id_ofs_list = NULL, .list_length = 83, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
.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
},
{
.range_start = 9654, .range_length = 1, .glyph_id_start = 181,
.unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY
}
};
@@ -945,7 +966,7 @@ static lv_font_fmt_txt_dsc_t font_dsc = {
.cmaps = cmaps,
.kern_dsc = NULL,
.kern_scale = 0,
.cmap_num = 2,
.cmap_num = 3,
.bpp = 1,
.kern_classes = 0,
.bitmap_format = 0,
+533 -38
View File
@@ -1,11 +1,15 @@
/*
* 模块名称:UI Menu (菜单页面构建)
* 模块功能:基于 lv_menu 复现 DTU-HMI 树形菜单,支持方向键导航与返回
* 模块功能:基于 LVGL 基元控件(页面导航)复现 DTU-HMI 树形菜单,支持方向键导航与返回
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-08-29
* 修改记录:
* 2026-08-29 王建锋 创建初始版本
* 2026-09-02 王建锋 弃用已废弃的 lv_menu 控件,改为基元控件页面导航
* 2026-09-02 王建锋 修复选中行文字不可见(白底白字):行改用 label 承载,选中态样式直接覆盖文字色
* 2026-09-03 王建锋 主界面按设计稿重构:顶部开口浅 V 装饰 + 状态行 + 左对齐带框 7 行列表(选中行整行反色、无箭头,其余行带 ▶);
* 根页条目收敛为一级 7 项(排除"厂家设置"子树);重命名 定值参数→参数定值、遥测量→三遥设置、通讯设置→通讯参数
*/
#include "ui_menu.h"
@@ -27,7 +31,7 @@ static const ui_menu_node_t g_menu_tab[] = {
{2, "交流量"},
{2, "直流量"},
{2, "开入量"},
{1, "定值参数"},
{1, "参数定值"},
{2, "定值"},
{3, "定值"},
{3, "遥调"},
@@ -38,7 +42,7 @@ static const ui_menu_node_t g_menu_tab[] = {
{3, "遥调"},
{3, "零压"},
{3, "电压"},
{1, "遥测量"},
{1, "三遥设置"},
{2, "测量值"},
{2, "遥调系数"},
{2, "遥测系数"},
@@ -49,7 +53,7 @@ static const ui_menu_node_t g_menu_tab[] = {
{2, "强制复归"},
{2, "手动录波"},
{2, "自动录波"},
{1, "通讯设置"},
{1, "通讯参数"},
{2, "通讯参数"},
{2, "网络配置"},
{2, "SNTP设置"},
@@ -85,31 +89,418 @@ static const ui_menu_node_t g_menu_tab[] = {
{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) */
/*
* 函数功能:构建菜单(lv_menu 页面树),复现参考项目菜单结构
* 页面:持久容器 + 按导航动态建毁的条目
* 容器常驻对象树(焦点对象不可删除,避免输入设备悬空指针),
* 条目按钮进入页面时创建、返回时销毁,内存占用 = 当前路径
*
* 每条目 = 行容器(整行背景)+ 文本 label + 可选箭头 label:
* - 行容器承载整行反色高亮(选中态白底)
* - 文本/箭头为子 label(绝对定位);选中态文字色须显式置黑
* (主题对 label 直接给白字,父级继承不可靠,见 2026-09-02 记录)
*/
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 白边框 */
/*
* 函数功能:初始化菜单样式(静态样式,仅执行一次)
* 入口参数:无
* 返回值:无
* 限定条件:无
* 函数说明: 1. flat 用于剥离主题给对象附加的卡片样式(边框/圆角/内边距)
* 2. selected 为选中条目整行反色高亮(1bpp:白底;文字色在刷新时显式置黑)
* 3. 样式后加优先,可覆盖主题在创建时附加的样式
*/
static void menu_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_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);
}
/*
* 函数功能:向页面添加一个条目(行容器 + 文本 + 可选箭头)
* 入口参数: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 *
* 返回值:无
* 限定条件:ui_menu_init() 已完成
* 函数说明:1. 隐藏旧页、显示新页(仅切换 HIDDEN 标志)
* 2. 页头仅子页显示;根页隐藏页头(让位顶部装饰 + 状态行,内容区撑满 160)
* 3. 焦点移至新页容器(按键输入入口)
*/
static void menu_show_page(ui_menu_page_t *page)
{
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);
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);
}
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);
}
break;
case LV_KEY_ENTER:
case LV_KEY_RIGHT:
menu_open_selected(page);
break;
case LV_KEY_ESC:
case LV_KEY_LEFT:
menu_go_back(page);
break;
default:
break;
}
}
/*
* 函数功能:返回按钮点击回调(预留触摸输入)
* 入口参数:e - 事件指针 lv_event_t *
* 返回值:无
* 限定条件:ui_menu_init() 已完成
* 函数说明:返回上级页面;按键输入由页面容器 ESC/LEFT 处理
*/
static void menu_back_btn_cb(lv_event_t *e)
{
(void)e;
if (s_cur != NULL) {
menu_go_back(s_cur);
}
}
/*
* 函数功能:应用主界面顶部"浅 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 - 成功,其他 - 失败(对象创建失败)
* 限定条件:ui_port_init() 已成功调用,活动屏幕存在
* 函数说明:1. 仅构建主页面(根页 + 一级菜单项),子树暂不构建以控制内存占用
* 2. 文本全部使用静态字符串(不 strdup),减少 LVGL 堆分配
* 3. 构建中监控 LVGL 池空闲内存,不足时中止而非崩溃
* 限定条件:ui_port_init()/sim_port_init() 已成功调用,活动屏幕存在
* 函数说明: 1. 子页页头(返回按钮 + 标题)+ 内容区,4 个页面容器(每深度一个,持久)
* 2. 主界面(根页):顶部开口浅 V 装饰 + 状态行 + 左对齐带框 7 行列表
* 3. 页面按需构建:ENTER 时建、返回时毁,内存 = 当前路径
* 4. 页面容器加入默认输入组,按键由容器统一处理
* 5. 构建前后监控 LVGL 池内存并打印
*/
int ui_menu_init(void)
{
lv_obj_t *menu = lv_menu_create(lv_screen_active());
if (menu == NULL) {
lv_obj_t *scr = lv_screen_active();
if (scr == NULL) {
return -1;
}
lv_obj_set_size(menu, 160, 160);
lv_menu_set_mode_root_back_button(menu, LV_MENU_ROOT_BACK_BUTTON_ENABLED);
lv_group_add_obj(lv_group_get_default(), menu);
/* 仅构建主页面:根页 + 一级菜单项(静态文本,不建子树,省内存) */
lv_obj_t *root_page = lv_menu_page_create(menu, NULL);
if (root_page == NULL) {
return -1;
}
lv_menu_set_page_title_static(root_page, "主界面");
menu_styles_init();
lv_mem_monitor_t mon;
lv_mem_monitor(&mon);
@@ -117,28 +508,132 @@ int ui_menu_init(void)
(unsigned)mon.total_size,
(unsigned)mon.free_size, (unsigned)mon.free_biggest_size);
uint32_t n = sizeof(g_menu_tab) / sizeof(g_menu_tab[0]);
for (uint32_t i = 1; i < n; i++) {
if (g_menu_tab[i].level != 1) {
continue; /* 只显示一级菜单项,子树暂不构建 */
}
/* 根容器:铺满屏幕,纵向布局 */
lv_display_t *disp = lv_display_get_default();
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, lv_display_get_horizontal_resolution(disp),
lv_display_get_vertical_resolution(disp));
lv_obj_set_flex_flow(menu, LV_FLEX_FLOW_COLUMN);
/* 内存警戒:空闲池不足则中止,避免 LVGL 内部 NULL 解引用崩溃 */
lv_mem_monitor(&mon);
if (mon.free_biggest_size < 1024) {
DBG_ERROR("menu mem low: abort at item %u (free=%u big=%u)",
(unsigned)i, (unsigned)mon.free_size,
(unsigned)mon.free_biggest_size);
break;
}
/* 子页页头:返回按钮 + 标题(根页隐藏) */
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);
const char *name = g_menu_tab[i].name;
lv_obj_t *item = lv_menu_cont_create(root_page);
lv_obj_t *lbl = lv_label_create(item);
lv_label_set_text_static(lbl, name);
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) {
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_menu_set_page(menu, root_page);
/* 主界面(根页):顶部"开口浅 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]);
lv_mem_monitor(&mon);
DBG_INFO("menu build done: total=%u free=%u big=%u used=%u",
+6 -5
View File
@@ -1,11 +1,12 @@
/*
* 模块名称:UI Menu (菜单页面构建)
* 模块功能:基于 lv_menu 复现 DTU-HMI 树形菜单,支持方向键导航与返回
* 模块功能:基于 LVGL 基元控件(页面导航)复现 DTU-HMI 树形菜单,支持方向键导航与返回
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-08-29
* 修改记录:
* 2026-08-29 王建锋 创建初始版本
* 2026-09-02 王建锋 弃用已废弃的 lv_menu 控件,改为基元控件页面导航
*/
#ifndef UI_MENU_H
#define UI_MENU_H
@@ -15,13 +16,13 @@ extern "C" {
#endif
/*
* 函数功能:构建菜单(lv_menu 页面树),复现参考项目菜单结构
* 函数功能:构建菜单(基元控件页面导航:页头返回按钮 + 页面切换)
* 入口参数:无
* 返回值:0 - 成功,其他 - 失败(对象创建失败)
* 限定条件:ui_port_init() 已成功调用,活动屏幕存在
* 函数说明:1. 依据紧凑节点表按层级生成 lv_menu 页面与返回栈
* 2. 将菜单对象加入默认输入组,支持远端按键导航
* 3. 叶子节点挂占位页,等待后续逐项填充具体功能
* 函数说明:1. 依据紧凑节点表按层级组织页面,ENTER 进入子页、ESC/LEFT 返回
* 2. 页面容器加入默认输入组,按键由当前页容器统一处理
* 3. 页面按需构建(进入时建、返回时毁),叶子节点为空白占位页
*/
int ui_menu_init(void);
+9 -6
View File
@@ -11,12 +11,12 @@
#define LV_COLOR_DEPTH 1
#define LV_COLOR_CHROMA_KEY 0x000000
/* 必需控件(lv_menu 依赖 flex/label/button) */
/* 必需控件(菜单依赖 flex/label/button) */
#define LV_USE_OBJ 1
#define LV_USE_FLEX 1
#define LV_USE_LABEL 1
#define LV_USE_BUTTON 1
#define LV_USE_MENU 1
#define LV_USE_MENU 0
/* 刷新周期(ms):离屏渲染,无需高刷 */
#define LV_DEF_REFR_PERIOD 20
@@ -71,9 +71,12 @@
#define LV_USE_THEME_BASIC 0
/* 绘制相关:1bpp 无需 GPU/抗锯齿 */
#define LV_DRAW_SW_COMPLEX_GRAD 0
#define LV_DRAW_SW_YIELD 0
#define LV_DRAW_SW_ASYNC 0
/* 复杂绘制必须开启:主界面顶部开口浅 V 装饰含斜线,lv_line 斜段依赖 LV_DRAW_SW_COMPLEX,
* 关闭则两腿不渲染(只画水平线)。原值靠 lv_conf_internal 默认(=1)隐式生效,此处显式固化。
* 注意:LV_DRAW_SW_CIRCLE_CACHE_SIZE 须非零(默认 4),否则零长数组编译报错。 */
#define LV_DRAW_SW_COMPLEX 1
#define LV_DRAW_SW_YIELD 0
#define LV_DRAW_SW_ASYNC 0
/* 输入设备:启用键盘/编码器(远程按键走 keypad) */
#define LV_USE_KEYBOARD 0
@@ -117,7 +120,7 @@
#define LV_USE_TABLE 0
#define LV_USE_CALENDAR 0
#define LV_USE_CANVAS 0
#define LV_USE_IMAGE 1 /* lv_menu 返回图标依赖 */
#define LV_USE_IMAGE 0 /* 菜单未用图片控件;lv_menu 已关,无图标依赖 */
#define LV_USE_TEXTLINE 0
#endif /* LV_CONF_H */
@@ -7,7 +7,7 @@
### 1.1 背景
STM32F4-Base 的 UI 是 **160×160 1bpp 单色**菜单(`lv_menu` 树形菜单),使用自定义中文字体 `ui_font_cn_12`。在 MCU 上开发 UI 需要反复烧录调试,效率低。本模拟器在 PC 上直接编译运行相同的 UI 代码(`ui_menu.c`),实现所见即所得。
STM32F4-Base 的 UI 是 **160×160 1bpp 单色**菜单(基元控件页面导航:`lv_obj` 页面 + 返回按钮切换),使用自定义中文字体 `ui_font_cn_12`。在 MCU 上开发 UI 需要反复烧录调试,效率低。本模拟器在 PC 上直接编译运行相同的 UI 代码(`ui_menu.c`),实现所见即所得。
### 1.2 架构
@@ -76,9 +76,12 @@ 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
```
> **▶ 箭头自动合并**:宋体(`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)时可忽略此步。
参数说明:
- `--bpp 1`:单色无抗锯齿;体积最小。**注意** lv_font_conv 在 1bpp 下斜线/细笔画偶发"丢像素",宋体尤甚。
- `--bpp 2/3`:字形带灰度,渲染到 1bpp 屏时按 `LV_DRAW_SW_I1_LUM_THRESHOLD` 阈值化,笔画更连贯;子集仅几十~上百字,体积仍很小。
+421
View File
@@ -0,0 +1,421 @@
# UI 界面制作流程(图片 → HTML → LVGL)
> **用途**:规范"参考图 → HTML 样机 → LVGL 代码 → 双端验证"的完整管线,规定每一步的**动作、产物、放行条件(gate)**,保证过程可重复、可交接。
> **适用**:所有 UI 新界面 / 界面大改;小改动走 §11 快速通道。
> **配套文档**:`UI_设计架构.md`(系统如何工作)、`LVGL模拟器部署与使用说明.md`(模拟器)、`UI_中文字体生成与配置.md`(字体生成)。
> **首个实例**:界面 01 主界面(根页),样机 `mockups/01_主界面.html`(本文所有示例数字均取自该试点)。
---
## 一、流程总览
```
参考图 ──▶ [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 归档] ──▶ 变更记录/台账更新
```
### 1.1 各阶段职责分工
| 阶段 | 用户做 | AI 做 | 产物 |
|---|---|---|---|
| P0 需求录入 | 提供图片(每批 1~3 屏);说明各屏作用与菜单位置;回答问题 | 图片分析、界面登记、问题清单 | 台账 + Q 清单 |
| P1 图片→HTML | 浏览器打开确认;指出问题**或直接改 HTML** | 度量核对、字符集核对、布局计算、样机制作与修改 | 定稿样机 |
| P2 HTML→代码 | (可选)抽查代码 | 逐参数翻译、字体重生成、内存预算 | 代码 + 字体 |
| P3 模拟器 | (可旁观) | 构建、离线出图、与样机渲染图比对、依 HTML 改码 | 比对结论 |
| P4 MCU | **真机目检**(DTU-RemoteLCD) | @build / @test 执行、日志解析 | 日志 + 结论 |
| P5 归档 | — | 更新变更记录与台账 | 文档 |
---
## 二、设计约束(硬规则)
管线中一切设计必须满足以下约束;违反即需重做,不存在"真机上再凑合"的选项。
| # | 约束 | 值 | 来源 |
|---|---|---|---|
| C1 | 屏幕 | 160×160,1bpp 单色(`LV_COLOR_FORMAT_I1`),无物理屏(远端客户端显示) | `ui_port.h` |
| C2 | 字体 | **唯一**:`ui_font_cn_12`(宋体 12px 1bpp)。汉字 12px/字,ASCII 6px/字,行高 13px。需要其他字号 = 大工程(新字体 + 双端 `lv_conf.h`),默认禁止 | `ui_font_cn_12.c` |
| C3 | 字符集 | 子集字体(当前 78 汉字 + ASCII 0x20-0x7E);**缺字显示空白**;新字必须重生成字体 | `tools/font_gen/` |
| C4 | LVGL 内存池 | 40KB(`LV_MEM_SIZE`);任一时刻只放"当前导航路径 + 当前内容页" | `Lib/lv_conf.h` |
| C5 | 文本 | 一律静态字符串(`lv_label_set_text_static`),禁止动态字符串/strdup | 架构文档 |
| C6 | 编码 | `Drivers/BSP/UI/*`、`ui_task.c`、`Lib/lv_conf.h` 为 **UTF-8 带 BOM**;改动只用 Edit 工具,**禁止** PowerShell/cmd 整文件重写(U+FFFD 不可逆) | AGENTS.md 铁律 |
| C7 | 代码共用 | `ui_menu.c` / 字体为 MCU 与模拟器共用源,**禁止**在 `simulator/` 复制副本 | 架构文档 |
| C8 | 视觉禁区(1bpp) | 圆角 / 阴影 / 透明度 / 渐变 / 多色 / 抗锯齿 全部禁用;样机与代码都不得引入 | 物理限制 |
| C9 | 双端注册 | 新增 `Drivers/BSP/UI/*.c` 必须**两处**手动注册:Keil `.uvprojx` + `simulator/CMakeLists.txt` 的 `UI_SOURCES` 列表(均为显式列表,无通配扫描)。只注册一端 = 另一端静默缺文件 | 实测(2026-09-03) |
### 2.1 字体度量速查(2026-09-03 实测,字体变更时重测)
| 项 | 原始值 | = 设备像素 | 说明 |
|---|---|---|---|
| 汉字步进宽 | `adv_w = 192`(1/16 px) | **12 px/字** | 4 字标签 = 48px |
| ASCII 步进宽 | `adv_w = 96` | **6 px/字** | "OK" = 12px,"V1.0" = 24px |
| 汉字墨迹盒 | `box_w` 9~11,`box_h` 11~12 | 墨迹在盒内再内缩 1~2px | **字体自带侧边距**,贴边文字仍有视觉留白 |
| 行高 | `line_height = 13` | 13px | 布局按 13 算,不是 12 |
| 基线 | `base_line = 2`(自底部) | — | 垂直定位参考 |
> 样机字体必须用 **SimSun**(设备字体的源字体),浏览器渲染宽度与设备度量一致,预览才"诚实"。
---
## 三、P0:需求录入与界面台账
### 3.1 用户输入
1. 参考图(建议每批 1~3 屏,不必一次给全)
2. 每图一句话:是什么界面、干什么用、挂在菜单哪个位置
3. 对 AI 提出的问题的回答(能答则答,答不了的标"待定")
### 3.2 AI 动作:图片分析(检查单)
| 分析项 | 内容 |
|---|---|
| 总体结构 | 页头 / 主体(网格/列表/数值/曲线/表单)/ 页脚 / 状态栏 |
| 元素类型 | 网格、列表、数值、曲线、开关、进度条、图标(1bpp 小屏图标基本不可行 → 文本替代) |
| 文本内容 | **逐字抄录**(字符集核对的输入;漏一个字 = 真机一处空白) |
| 比例换算 | 参考图比例 → 160×160 设备像素(参考屏分辨率通常大于 160,做**比例适配**而非像素照搬) |
| 风格 | 极性(白底黑字/黑底白字)、边框样式、选中样式 |
| 信息缺失 | 截图裁切、遮挡、模糊处 → 转成待确认问题 |
### 3.3 界面台账
- 编号 `NN`(01 = 试点界面),名称、作用、菜单位置、状态(待做/进行中/已完成)
- 台账随 P5 归档更新
### 3.4 Gate(进入 P1)
- [ ] 界面作用与菜单位置已确认
- [ ] 阻塞性问题已回答;非阻塞项已标注默认值(如 "TBD")
> **试点实例**:01 主界面。用户给一张根页截图:顶部梯形装饰 + 状态行"当前模式:无模式" + 7 行列表(装置信息/实时数据/参数定值/三遥设置/装置维护/通讯参数/记录查询),黑底白字(与现系统同极性)。AI 分析出:7 项与现有 `g_menu_tab` 的映射,其中 3 项疑似重命名(参数定值/三遥设置/通讯参数)→ Q1;"三遥设置"对应现有"遥测量"还是新增"三遥"节点 → Q2;旧菜单"厂家设置/版本信息"未列入 7 项,入口在哪 → Q3;"当前模式"指什么、数据从哪来 → Q4。
---
## 四、P1:图片 → HTML 样机
### 4.1 字体度量核对(字体每次变更时重做)
1. 读 `Drivers/BSP/UI/ui_font_cn_12.c` 头部 Opts 注释 → 字体源(宋体)、尺寸(12)、bpp(1)、**当前字符集**
2. 读 `glyph_dsc[]` 表 → `adv_w ÷ 16` = px(核对 CJK=12 / ASCII=6)
3. 读 `font_dsc` → `line_height=13`、`base_line=2`
4. 与 §2.1 速查不一致 → **全部样机参数重算**
### 4.2 字符集核对
1. 收集本屏全部文本中的**去重汉字**
2. 对照现有字符集(字体 `.c` 头部 `--symbols` 行为准;`ui_cn_chars.txt` 为生成物,重生成后一致)
3. 输出:命中列表(现有)/ 新增列表(标红)→ 记入配套 `_说明.md`
4. 新增列表决定 P2 字体重生成的范围
> **试点实例**:01 主界面 7 个菜单项共 22 个唯一汉字 → 命中 21(装置信息实时数据参数定值遥设维护通讯记录查询),**新增 1**(三);状态行"当前模式:无模式"新增 5(当前模式无);箭头 **▶**(U+25B6)为新增符号。合计**确定新增 7 个字形**(若状态行用全角冒号":"则再多 1,共 8)。
### 4.3 布局计算
- 所有值为**设备像素整数**
- 列宽:`cell_w = floor((W − 2×边距 − 间隙×(n−1)) / n)`,**1px 余量给最后一列**
- 行高:`cell_h = floor((H − 2×边距 − 间隙×(m−1)) / m)`
- 必须给出**验证等式**(每项相加 = 屏幕尺寸)
- 文字宽度检查:`字数 × 12px`(步进宽)vs 格内宽(格宽 − 2px 边框)。步进宽超出但**墨迹宽**(约 −1px/端)放得下即可;放不下 → 减字数 / 减列数 / 回 P0 问用户
> **试点实例(7 行列表)**:
> `160 = 顶边距2 + 顶装饰5 + 隙4 + 状态行13 + 隙5 + 列表框128 + 底边距3`
> 列表框 `144×128`(1px 边框,x=8):内容高 `7×18 + 2(上下边框) = 128`,7 行 × 18px
> 行文字 4字 = 步进 48px,行内宽 144(左留白 4px)→ 富余,**左对齐**成立
>
> 推论:行通栏 144px,文字**左对齐**(左留白 4px)+ 字体自带侧边距 = 视觉留白;行尾 ▶ 右对齐(右留白 4px)。选中行整行反色(白底黑字)。
### 4.4 样机制作
**文件**:`docs/UI 设计/mockups/NN_界面名.html`(**仅页面**)+ 配套 `NN_界面名_说明.md`(规格)
**HTML 样机(只渲染页面本身,无其他可见内容)**:
| 要求 | 规定 |
|---|---|
| 自包含 | 内联 CSS,无外部依赖,**无 JS**,双击即开(Chrome/Edge 最佳,需 SimSun) |
| **仅页面** | HTML 只渲染 160×160 页面;**不出现**标题/说明区/图例等任何非页面可见内容。规格、核对、问题等一律写入配套 `_说明.md` |
| 视图 | ① 1:1 实际大小(160×160,所见即真机)② 4x 放大(细节检查);选中/告警等状态若属设计一部分则直接画在页面内,不单列 |
| 4x 实现 | `--u` CSS 变量(1:1 取 `1px`,4x 取 `4px`),全部尺寸 `calc(N*var(--u))`,与 1:1 **同源**标记 |
| 字体 | `font-family: SimSun, NSimSun, serif`(与设备字体同源同宽) |
| 颜色 | 仅 `#000` / `#fff` |
| 定位 | 绝对定位 + 精确 px(对 LVGL `lv_obj_set_pos` 翻译最忠实);确属流式布局才用 flex |
| 文字对齐 | 默认左对齐(见 §4.3 推论);垂直居中 |
| 1bpp 禁区 CSS | `border-radius` / `box-shadow` / `opacity` / 渐变 / `transform` / z-index 层叠 —— 出现即违规 |
**配套 `NN_界面名_说明.md`(规格,顺序固定)**:
1. **布局参数表** —— 每个 px 值都列出(P2 翻译的依据,必须与样机 CSS 完全一致)+ 验证等式
2. **行/元素内容与状态**
3. **字符集核对结果** —— 命中 / 新增
4. **待确认问题** —— 沿用 P0 的 Q 编号
5. **映射建议**(菜单类界面:条目 → 子树)
6. **LVGL 翻译要点** —— 本屏具体的翻译提示(控件选择、状态、内存注意点)
**文件头注释**(HTML 顶部,简短):样机规则 + `状态:制作中 / 已定稿 (YYYY-MM-DD)` + 指向配套 `_说明.md`
### 4.5 原图校验循环(渲染 → 目视比对 → 修正)
> 目的:消除"AI 读图不准"的风险。样机完成后渲染成图,与原图并排目视比对,有差异就改,循环到基本一致。**不做像素级对齐**(字体字形本就不同,像素 diff 无意义)。
1. **渲染样机成图**:`python tools/ui_mock/render_mockup.py <mockup.html> <out.png>`(无头 Chrome/Edge 截 1:1 视图,得 160×160 PNG)
2. **AI 读两图比对**:直接读 `原图.png` 与渲染出的 PNG,目视逐项核对——装饰形状/位置、状态行位置与文案、列表框位置/大小、行高、选中行、箭头位置
3. **有差异 → 改 HTML → 重新渲染 → 再比对**,循环
4. **基本一致**:结构与原图无明显出入(允许字体字形微差)→ 交用户浏览器最终确认
5. 用户指出问题,或**直接修改 HTML** 后告知(HTML 即设计文件,用户改 = 设计变更)
6. **定稿**:几何/文案/状态/参数与用户意图一致 → 文件头注释标 `已定稿 (日期)`,此后改动须用户明确提出
### 4.6 Gate(进入 P2)
- [ ] 样机已定稿
- [ ] **原图校验通过**:渲染图与原图目视比对基本一致(§4.5 循环收敛)
- [ ] 待确认问题全部有答案或显式默认值
- [ ] 字符集核对完成(新增字清单确定)
---
## 五、P2:HTML → LVGL 代码
### 5.1 翻译对照表(CSS → LVGL v9)
| 样机(HTML/CSS) | LVGL(设备端) | 备注 |
|---|---|---|
| 160×160 白底画布 | display(I1)+ 屏幕背景色 | 极性随全局决策(Q2 类问题) |
| `left/top` 绝对定位 | `lv_obj_set_pos(o, x, y)` | 整数 px |
| `width/height` | `lv_obj_set_size(o, w, h)` | |
| flex row / row-wrap | `lv_obj_set_flex_flow(o, LV_FLEX_FLOW_ROW[_WRAP])` | |
| `gap` | `lv_obj_set_style_pad_gap(o, g, 0)` | |
| 1px 边框 | `lv_style_set_border_width(st,1)` + `border_color` | |
| 仅底边框 | `lv_style_set_border_side(st, LV_BORDER_SIDE_BOTTOM, 1)` | v9 分边设置 |
| 背景色 | `lv_style_set_bg_color` + `lv_style_set_bg_opa` | 1bpp 仅纯黑/纯白 |
| 文本 | `lv_label_create` + `lv_label_set_text_static` | 静态字符串(C5) |
| 文字对齐 | 标签定位 / `lv_label_set_long_mode` | 默认左对齐 |
| 选中反色 | `LV_STATE_CHECKED` 样式(bg/文字色互换) | 条目对象**自身**带 CHECKED(防白底白字,架构 §5.5) |
| `display:none` | `LV_OBJ_FLAG_HIDDEN` | 页面/状态切换 |
| 圆角/阴影/透明/渐变 | **禁止翻译**(C8) | 样机里就不该有 |
### 5.2 文件策略与双端注册
- **首选原地扩展** `ui_menu.c`(共用源,改一处两端生效)
- 确需新文件(模块独立,如曲线页渲染器)时,**两处注册**(C9):
1. Keil:`MDK-ARM/*.uvprojx` 手动添加
2. 模拟器:`simulator/CMakeLists.txt` 的 `UI_SOURCES` 列表加一行
- 新**页面类型**(内容页/编辑页)扩展现有"容器持久、条目动态"架构,**禁止**另起第二套架构
- 字体文件 `ui_font_cn_12.c` 两端均已注册 → 重生成是纯内容替换,无需注册
### 5.3 翻译纪律
1. **逐参数翻译**:代码中每个 px 值必须能在样机"布局参数表"中找到出处;禁止"看着办"
2. 样式沿用现有 `s_style_flat` / `s_style_selected` 模式(静态样式、一次性初始化、后加优先覆盖主题)
3. 焦点对象恒为**页面容器**;条目不入输入组(架构 §5.4)
4. 容器**永不删除**(indev 释放事件 use-after-free,架构 §5.2);只建/毁条目
5. 编码纪律(C6):Edit 工具精确替换;文件保持 UTF-8 BOM 不变
6. 注释按代码规范 V1.0:文件头模块块 + 函数头块(功能/参数/返回/限定/说明)
### 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(全量重生成 + 自动补回宋体缺失的 ▶ 箭头,幂等)
4. 生成物 diff 检查:新增字在位、旧字不变、Opts 行字符集与预期一致
```
- 一次性环境准备:`npm i -g lv_font_conv`、`pip install fonttools`
- `ui_cn_chars.txt`(仓库根,受 git 跟踪)与 `ui_font_cn_12.c` **随代码一起提交**
- 双端 `LV_FONT_DEFAULT` 均指向 `&ui_font_cn_12` → 重生成后两端自动生效
- P1 已知的新增字若含生僻字(宋体可能无此字形)→ 在写码前先手动验证字形,避免"代码写完才发现是空白"
- **已处理实例:▶(U+25B6)箭头**——宋体无此字形,`lv_font_conv` 静默跳过 → 真机方框。`make_font.py` 已内置 `merge_arrow_glyph()` 自动从 Segoe UI Symbol 补回(幂等),重生成字体后箭头不丢。换用自带 ▶ 的字体时可忽略
### 5.5 内存预算
- 估算:`对象数 × 单价`(`lv_obj` ≈ 150B、`lv_label` ≈ 200B 量级,以实测为准)
- **实测点**:建页前后打 `lv_mem_monitor` 池水位(沿用现有 `menu build start/done` 日志模式)
- 红线:**最坏路径**(根页 + 最深下钻 + 当前内容页)< 40KB 且留 ≥25% 余量
- 超预算 → 回到 P1 减元素 / 改按需建毁策略;不得靠加大 `LV_MEM_SIZE` 硬扛(RAM 总预算紧张)
### 5.6 Gate(进入 P3)
- [ ] 代码与样机参数逐项一致(自查对照表)
- [ ] 字体已重生成且 diff 检查通过
- [ ] 内存预算通过(估算 + 代码中埋好水位日志)
- [ ] 新文件(如有)双端注册完成
---
## 六、P3:模拟器验证
### 6.1 动作
1. `cd simulator; cmake --build build --config Debug`(改过 `CMakeLists.txt` 才需重新 `cmake -B`)
2. **离线出图**(模拟器页面渲染完成后):
- `.\build\Debug\lvgl_sim.exe --screenshot shot.bin` → 离屏渲染首帧,输出 I1(1bpp) 位图
- `python i1_to_png.py shot.bin -o shot.png --scale 4` → 160×160 PNG(放大便于比对)
3. **读两图比对**:模拟器 `shot.png` vs 样机渲染图 `mockups/render_NN.png`,按 §6.2 检查单逐项核对
(样机渲染图由 P1 的 `tools/ui_mock/render_mockup.py` 产出,即本屏"输入图片")
4. **不合适 → 依 HTML 样机改 LVGL 代码**(HTML 为设计基准)→ 重构建 → 重新出图 → 再比对,循环至一致
5. 交互行为(导航/滚动/选中)在窗口模式 `.\build\Debug\lvgl_sim.exe` 内按键验证(离线出图只覆盖首帧静态画面)
### 6.2 比对检查单
| 项 | 通过标准 |
|---|---|
| 几何 | 位置/尺寸/边框与 1:1 视图逐格一致(容差 1px) |
| 文本 | 内容完整、**无空白块**(空白 = 缺字 → 回 §5.4)、宽度/位置正确 |
| 状态 | 选中反色正确(极性正确:黑底白字↔白底黑字不反) |
| 导航 | ↑↓ 移动(环绕)、→/Enter 进入、←/Esc 返回;滚动条行为正常 |
| 日志 | `menu build done: used=…` 在预算内;无 ERROR/FAIL |
### 6.3 已知不阻塞差异(不修)
- 浏览器灰边抗锯齿 vs 1bpp 硬边
- 浏览器 TTF 渲染 vs 位图字体的字形微差(同源宋体,形状近似即可)
- 系统 DPI 缩放导致的窗口显示大小差异
### 6.4 Gate(进入 P4)
- [ ] 模拟器出图 `shot.png` 与样机渲染图 `render_NN.png` 逐项同构(§6.2,忽略 6.3)
- [ ] 导航行为符合键位语义表(架构 §3.4)
---
## 七、P4:MCU 验证
1. `@build` → **0 错误 0 警告**(AGENTS.md 强制;有警告看 `MDK-ARM/build_log.txt`)
2. `@test`(一键 `test/run_boot_test.ps1`):
- 采集窗口**先于烧录武装**(脚本 `-FlashCmd` 分支已保证顺序)
- Required:`UI port init: OK;UI menu init: OK` + 本界面定制标记(按屏增补,如页头标题字样)
- Forbidden:`FAIL;ERROR;HardFault`
3. **真机目检**(用户执行,DTU-RemoteLCD @ TCP 7003):极性 / 边框 / 文字 / 选中态 / 滚动
4. 串口日志:池水位、任务栈高水位(`uxTaskGetStackHighWaterMark`,uiTask 预算 16384 words)
### Gate(进入 P5)
- [ ] @build 0错0警
- [ ] @test PASS
- [ ] 真机目检通过(用户确认)
---
## 八、P5:归档
1. 更新 `docs/UI 设计/UI_设计架构.md`:
- 变更记录(日期 + 一行)
- 受影响章节:文件地图 / 关键参数 / 菜单架构 / 键位语义
2. HTML 样机**保留**于 `mockups/`(定稿版 = 设计档案,后续改界面须先改样机再改代码)
3. 台账:该界面状态 → 已完成(日期)
4. 提交检查:代码 + 字体(`ui_font_cn_12.c`)+ `ui_cn_chars.txt` + 样机 HTML + 文档 一起进 git
---
## 九、批量生产策略
1. **试点先行**:01 主界面走完全流程,校准各阶段耗时、翻译损耗点、内存基线(试点已完成 P1,后续阶段数据回填本文档)
2. **按界面族批量**:
- 列表页(复用现有条目行架构)→ 数据显示页(数值行框架)→ 参数编辑页(编辑/确认交互)→ 曲线/状态页
- 每族先做**族内第一屏**产出可复用的页面框架,再批量铺同族界面
3. **每批收尾**:字体统一重生成(各屏字符集**并集**,一次生成)→ 全量回归(已完成界面重跑 @test)
4. 字符集只增不减;字体文件每次**全量重生成**,禁止手工拼合
---
## 十、常见坑速查
| 坑 | 现象 | 对策 |
|---|---|---|
| PowerShell 重写含中文文件 | 整文件 U+FFFD 乱码(不可逆) | 只许 Edit 工具(C6) |
| 用了字体子集外的字 | 该字显示空白 | P1 字符集核对 + P2 字体重生成 |
| 字体缺字形(如宋体无 ▶) | 该字符显示成方框 □ | `make_font.py` 已内置 `merge_arrow_glyph()` 自动补回;其余缺字形同法(从有该字的字体提取位图合并,§5.4) |
| 新 .c 只注册了 Keil(或只改了 CMake) | 单端编译缺函数/静默缺失 | 双端注册(C9) |
| 模拟器主循环调 `SDL_PollEvent` | 键盘全部无响应 | 事件只由 LVGL SDL 驱动内部轮询(架构 §5.7) |
| 选中行"白底白字" | 选中文字不可见 | 条目行用 label 自身携带 CHECKED 态(架构 §5.5) |
| 按键处理里删除焦点容器 | use-after-free 崩溃 | 容器持久不删(架构 §5.2) |
| LVGL 池爆 40KB | 对象创建失败/画面残缺 | §5.5 预算 + 按需建毁 |
| 极性配反 | 黑屏/白屏/不可读 | 模拟器 mono 主题 `dark_mode` 参数 + MCU 端背景色一致 |
| 步进宽接近/超出格宽的标签居中 | 亚像素错位/不对称 | 左对齐 + 字体侧边距(§4.3) |
| 关闭了 `LV_DRAW_SW_COMPLEX`(省代码) | `lv_line` 斜线不绘制(如主界面顶部"浅 V"两腿消失,只剩水平线) | `LV_DRAW_SW_COMPLEX` 两端显式 =1;`LV_DRAW_SW_CIRCLE_CACHE_SIZE` 须非零(默认 4,否则零长数组 C2466)(架构 §关键约束 7) |
| 斜线用细 `line_width`(如 2px) | 1bpp 阈值吃掉斜线边缘,腿偏细、比设计稿细 | 斜线加粗(腿 3px),或按段拆 `lv_line` 分粗细(如"浅 V"两腿 3 / 水平线 2) |
| 样机用了花哨 CSS | P2 翻译不出来 | 1bpp 禁区(C8),P1 阶段就拦下 |
---
## 十一、小改动快速通道(简化流程)
| 改动类型 | 流程 |
|---|---|
| 文案修改 | 改样机 → 改代码 → **字符集核对**(有新字则重生成字体)→ P3 比对 |
| 间距/尺寸调整 | 改样机参数表 → 改代码 → P3/P4 合并为一轮验证 |
| 纯交互调整(键位/滚动) | 改样机/配套说明 → 改代码 → P3 验证行为 |
| 字体重生成 | 任何引入新字的改动后强制执行 §5.4(**最常见的漏项**) |
---
## 附录 A:样机 HTML 模板骨架(仅页面)
```html
<!--
界面 NN:<名称>
状态:制作中 / 已定稿 (YYYY-MM-DD)
规则:160×160 设备像素 | 1bpp 纯黑白 | SimSun 12px(汉字 12px/字,ASCII 6px/字,行高 13)
本文件仅渲染页面本身;规格见 NN_<名称>_说明.md
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>界面 NN | <名称></title>
<style>
html, body { margin:0; }
body { padding:24px; background:#e8e8e8;
display:flex; gap:24px; align-items:flex-start; flex-wrap:wrap; }
/* 1 设备像素 = var(--u);1:1 取 1px,4x 取 4px,同源放大 */
.ui { --u:1px; position:relative;
width:calc(160*var(--u)); height:calc(160*var(--u));
background:#fff; color:#000; overflow:hidden;
font-family:SimSun,NSimSun,serif; }
.ui.z4 { --u:4px; }
/* 元素:绝对定位 + 精确 px,全部 calc(N*var(--u));
禁用 border-radius / box-shadow / opacity / 渐变 / transform */
.el { position:absolute; box-sizing:border-box; }
</style>
</head>
<body>
<!-- 仅页面,无标题/说明/图例。选中/告警等状态若属设计一部分则直接画在页面内。 -->
<div class="ui">…标记…</div> <!-- ① 1:1 -->
<div class="ui z4">…同标记…</div> <!-- ② 4x -->
</body>
</html>
```
## 附录 B:单界面生产检查单(复制使用)
```
界面 NN:<名称> 负责人:____ 开始:____
P0 [ ] 台账登记(编号/作用/菜单位置)
P1 [ ] 图片分析(结构/元素/文本逐字/比例/缺失)
[ ] 字体度量核对(与 §2.1 一致)
[ ] 字符集核对:__ 命中 / __ 新增
[ ] 布局计算(验证等式:____________)
[ ] 样机 HTML 产出(仅页面:1:1 / 4x)
[ ] 配套 NN_名称_说明.md 产出(布局/内容/字符集/问题/映射/LVGL 要点)
[ ] 原图校验:渲染成图 + 读两图目视比对,循环至基本一致(§4.5)
[ ] 用户浏览器确认定稿(日期:____)
P2 [ ] 代码翻译(与样机参数逐项一致)
[ ] 字体重生成(extract → make_font → diff 检查)
[ ] 新文件双端注册(如适用,C9)
[ ] 内存预算(实测水位:____KB / 40KB)
P3 [ ] 模拟器构建 0 错误
[ ] 离线出图:--screenshot shot.bin → i1_to_png.py → shot.png
[ ] shot.png 与样机渲染图(render_NN.png)逐项比对一致(几何/文本/状态);不一致依 HTML 改码再出图(§6.1)
[ ] 导航行为验证(窗口模式按键)
P4 [ ] @build 0 错 0 警
[ ] @test PASS(Required:____ / Forbidden:FAIL;ERROR;HardFault)
[ ] 真机目检通过
P5 [ ] UI_设计架构.md 变更记录更新
[ ] 台账状态 → 已完成(代码+字体+字符集+样机+文档 一并提交)
```
---
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-09-03 | 初始版本:基于 01 主界面试点(P0/P1 已执行)沉淀全流程规范 |
| 2026-09-03 | P3 增加"离线出图比对":模拟器 `--screenshot` 出图 + `simulator/i1_to_png.py` 转 PNG,与样机渲染图(render_NN.png)逐项比对,不合适则依 HTML 改 LVGL 代码再出图,循环至一致 |
| 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) |
+228
View File
@@ -0,0 +1,228 @@
# UI 设计架构
> **用途**:说明 UI 系统**如何实际工作**(渲染/输入/菜单架构)与**设计考量**(为什么这样做)。
> **配套文档**:`LVGL模拟器部署与使用说明.md`(模拟器部署/使用)、`UI_中文字体生成与配置.md`(字体生成)。
---
## 一、UI 系统速览
### 1.1 总体方案
MCU **无物理屏幕**:LVGL 做 160×160 **1bpp 单色离屏渲染**,帧缓冲(3200 B)经 **RemoDispBus 协议**(TCP 端口 7003)按需推给远端客户端(DTU-RemoteLCD)显示;远端按键走同一通道回传。"屏幕"实际是 PC 端客户端。
### 1.2 文件地图
| 文件 | 端 | 职责 |
|---|---|---|
| `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_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) | 工具 | 字符集提取 / 字体生成 |
### 1.3 关键参数
| 项 | 值 | 位置 |
|---|---|---|
| 屏幕 | 160×160,1bpp(`LV_COLOR_FORMAT_I1`) | `ui_port.h` |
| 帧缓冲 | 3200 B(`g_ui_fbuf`,flush 回调整帧 memcpy) | `ui_port.c` |
| LVGL 内存池 | 40 KB(`LV_MEM_SIZE`) | `Lib/lv_conf.h` |
| 刷新周期 | 10 ms(`ui_port_step(10)` + `osDelay(10)`) | `ui_task.c` |
| 推流端口 | 7003(TCP,与 DTU-RemoteLCD 客户端对齐) | `ui_net_sink.h` |
| 默认字体 | `ui_font_cn_12`(12px 1bpp,78 汉字 + ASCII,约 35KB) | `Lib/lv_conf.h` |
| uiTask 栈 | 16384 words(BelowNormal) | `Src/freertos.c` |
### 1.4 硬约束(改 UI 必须遵守)
1. **RAM 紧张**:新增页面/控件/缓冲前必须核算 LVGL 池(40KB)与任务栈余量(见 `待处理内容.md`)
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/` 下复制副本
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)
---
## 二、渲染链路(画面怎么到"屏幕")
### 2.1 MCU 端(产品实际路径)
```
LVGL 场景(160×160,LV_COLOR_FORMAT_I1 单色)
→ SW 软件渲染器(仅启用 I1,裁掉其余色深)
→ flush 回调:整帧 memcpy 到内存帧缓冲 g_ui_fbuf(3200 B)
→ RemoDispBus 协议(TCP 7003):远端客户端发 LCDMEM 请求才回送整帧
→ DTU-RemoteLCD 客户端(PC)解码显示
```
- MCU **无物理屏幕**:"屏幕"是 PC 端客户端,帧缓冲只是网络传输的载体
- **按需拉取**:不主动推帧,客户端要才给(菜单是低频交互,够用且省带宽)
- LVGL 视角:`ui_port.c` 就是一个普通 display(I1 格式 + flush 回调),不知道帧最终走网络
### 2.2 模拟器端(PC 开发路径)
```
同一份 LVGL 场景(共用 ui_menu.c / ui_font_cn_12.c)
→ SW 渲染器(I1)
→ SDL 窗口 blit(LV_SDL_RENDER_MODE_PARTIAL,1bpp 强制要求)
→ 屏幕(160×160,高 DPI 下可能放大显示,属正常)
```
- 两端**场景层完全相同**(同一份 UI 源码),只有"渲染结果去向"不同:MCU 进帧缓冲走网络,模拟器进 SDL 窗口
- 模拟器必须初始化 **mono 主题**(`lv_theme_mono_init`):1bpp 下无主题 = 黑底黑字,全黑屏
## 三、输入链路(按键怎么到菜单)
### 3.1 统一模型(两端相同)
```
按键 → keypad 输入设备(LV_INDEV_TYPE_KEYPAD)
→ 默认输入组(lv_group)
→ 组内焦点对象 = 当前可见页面容器
→ 容器的 LV_EVENT_KEY 回调(menu_page_key_cb)
```
- keypad 输入设备经 `lv_group_send_data` 把键码发给**焦点对象**
- LVGL v9 的输入组**不自动注册默认键回调**(方向键移动焦点需焦点对象自己实现)
→ 页面容器是组内唯一焦点对象,按键全部由容器统一处理,无焦点争抢
- 按下时输入设备捕获焦点对象(indev_obj_act),释放时向其回发 RELEASED/CLICKED
→ 焦点对象(容器)必须存活,这是"容器持久不删"的原因之一(见 §5.2)
### 3.2 MCU 端
```
远端客户端按键 → TCP 7003 → ui_net_sink 解析
→ ui_port_queue_key(UI_KEY_* → LV_KEY_* 映射)
→ keypad 输入设备 read_cb 上报 → 默认输入组
```
### 3.3 模拟器端
```
SDL 键盘 → LVGL SDL 驱动内部 5ms 定时器(sdl_event_handler)
→ 键码映射(↑↓←→/Enter/Esc → LV_KEY_*)
→ keypad 输入设备 → 默认输入组
```
- ⚠️ 主循环**不得**再调 `SDL_PollEvent`:会先消费事件队列,按键全部失效(2026-09-02 实际踩坑,见 §5.7)
### 3.4 键位语义
| 键 | 行为 |
|---|---|
| ↑ / ↓ | 移动选中(首尾环绕);选中行反色高亮 + 自动滚动到可见区 |
| → / Enter | 打开选中条目的子页(叶子节点打开空白占位页) |
| ← / Esc | 返回上级页面(本页条目销毁,父页选中态不变) |
## 四、菜单架构(核心)
### 4.1 数据驱动:节点表
- `g_menu_tab[]`(`ui_menu.c`):62 个节点,每项 = 层级(0=根)+ 名称
- 树序即表序:某节点的子节点 = 其后**连续的同(level+1)层级段**
- 根页 = 全部一级节点(⚠️ 双根 `主界面`/`厂家设置` 混排在同一根页,已知问题,待拆分)
### 4.2 对象树
```
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)
```
- 4 个页面容器**持久存在**(每深度一个),永不删除;同一时刻仅一个可见(HIDDEN 切换)
- 每个容器:flex 纵向排列条目 + 自动滚动条 + 加入默认输入组 + 容器级 `LV_EVENT_KEY` 回调
- 页头返回按钮:真 `lv_button`,**不入输入组**(不参与键导航),点击回调预留供将来触摸输入;根页隐藏
### 4.3 条目行
- 行 = `lv_label`(整行一个 label:宽 100%、高随内容、文本左对齐)
- 行**不入输入组**:纯展示,按键一律由容器处理
- 选中行 = CHECKED 状态 → 反色(白底黑字,1bpp 下即"高亮")
- 行高 ≈ 字体 12px + 上下内边距 2×2 ≈ 16px;根页 12 行 ≈ 192px > 页面区 142px → 滚动条出现
### 4.4 页面生命周期(按需构建)
```
ENTER/→ 打开子页:
下一深度容器上构建条目(label 行)→ 隐藏旧容器、显示新容器 → 焦点移至新容器
←/Esc 返回:
销毁本页条目(lv_obj_clean,容器保留)→ 显示父容器 → 焦点移回父容器
```
- **容器持久、条目动态**:LVGL 池内存占用 = 当前路径(根页 12 行 + 各级子页,最坏约 25KB < 40KB 池)
- 叶子节点(无子节点):打开空白占位页(0 条目),Enter 无动作,Esc 返回
- 深度保护:depth+1 ≥ 4 不再下钻
### 4.5 焦点与按键处理
- 焦点对象 = 当前可见页面容器;切页时 `lv_group_focus_obj` 显式转移
- 容器回调 `menu_page_key_cb` 统一分发(见 §3.4 键位语义)
- 选中变化只改行的 CHECKED 状态 + `lv_obj_scroll_to_view` 跟随,不重建对象
## 五、设计决策与考量(为什么这样做)
### 5.1 弃用 lv_menu 控件
- `lv_menu` 已被 LVGL 官方废弃;官方建议用基元控件构建菜单:
`lv_obj` 构建页面 + 返回按钮切换可见页面(参考官方示例 `lv_example_menu_navigation`)
- 基元控件方案的好处:无废弃风险、样式完全自主可控、与 mono 主题/1bpp 单色兼容性好
- 配套动作:`LV_USE_MENU` 两端配置置 0(MCU `Lib/lv_conf.h` + 模拟器 `simulator/lv_conf.h`),省代码空间
### 5.2 页面容器持久不删
- keypad 输入设备在**按下时**捕获焦点对象,**释放时**向其回发 RELEASED/CLICKED
- 若在按键处理器内删除焦点对象(如"返回时销毁当前页"),释放事件将落在已释放内存上 → use-after-free
- 容器永不删除,回发事件始终落在存活对象上,天然安全;要释放的只是容器内的条目
### 5.3 条目按需建/销
- LVGL 池仅 40KB(`LV_MEM_SIZE`),全量预建 62 个节点的所有页面必然爆内存
- 只建"当前路径"上的页面:内存 = 根页 + 各级子页条目(最坏约 25KB)
- 返回即 `lv_obj_clean` 释放,内存随时回落到根页水位
### 5.4 容器是唯一焦点对象
- v9 输入组不自动处理方向键(无默认键回调),焦点移动完全由焦点对象自己实现
- 容器集中处理按键:逻辑单点、行为可预测、无多对象焦点争抢
- 条目行不入组:行只负责"显示选中态",不参与焦点/点击,避免行与容器抢键
### 5.5 条目行用 label 而非 button+子 label
- 样式解析规则(`lv_obj_style.c`):**先查对象自身样式**,查不到才向父对象继承(继承时用**父对象**的状态)
- 子 label 自带主题 card 样式(文字色固定为白),自身样式总能命中 → 永远继承不到
父按钮的选中态文字色 → 选中行"白底白字",文字不可见(2026-09-02 实际踩坑)
- label 行**自身**携带 CHECKED 态:选中样式(白底黑字)对自身精确匹配,直接生效
- 行不需要按钮语义(按压反馈/点击):输入由容器统一处理,行是纯展示
### 5.6 flat 样式剥离主题 card
- mono 主题给所有基元对象附加 card 样式(背景/边框/圆角/内边距/文字色)
- 160×160 小屏 1bpp 菜单不需要卡片观感(边框圆角浪费像素)
- flat 样式(透明背景/无边框/无圆角/零内边距)**后加优先**,逐项覆盖 card
### 5.7 SDL 事件只由 LVGL 驱动轮询
- LVGL SDL 驱动内部有 5ms 定时器(`lv_sdl_window.c` 的 `sdl_event_handler`)统一 `SDL_PollEvent` 并分发
- 主循环若再 `SDL_PollEvent`,会**先消费**事件队列 → 驱动定时器轮询时队列已空 → 键盘全部失效
- 关窗退出由驱动 `LV_SDL_DIRECT_EXIT=1` 自动 `exit(0)`,主循环无需处理 SDL_QUIT
## 六、变更记录(简表)
| 日期 | 变更 |
|---|---|
| 2026-08-30 | LVGL v9.3.0 入库;初始 UI(ui_port / ui_menu / ui_net_sink / ui_task);开启 192K 全部 RAM |
| 2026-08-31 | 推流/按键链路打通;移除 LVGL 未用 demos/tests;搭建 PC 模拟器(CMake + SDL2) |
| 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 防零长数组 |
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<!--
界面 01:主界面(根页)样机 — 160×160
状态:制作中
规则:160×160 设备像素 | 1bpp 纯黑白 | SimSun 12px(汉字 12px/字,ASCII 6px/字,行高 13)
本文件仅渲染页面本身;规格见 01_主界面_说明.md
布局依据:原图 01_主界面原始图片.png 实测(628×631 @ 3.925x 缩放)
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>界面 01 | 主界面</title>
<style>
html, body { margin:0; }
body { padding:24px; background:#e8e8e8;
display:flex; gap:24px; align-items:flex-start; flex-wrap:wrap; }
/* ===== 屏体:1 设备像素 = var(--u) ===== */
.ui { --u:1px; position:relative;
width:calc(160*var(--u)); height:calc(160*var(--u));
background:#000; color:#fff; overflow:hidden;
font-family:SimSun,NSimSun,serif; }
.ui.z4 { --u:4px; }
/* 顶部装饰:开口浅 V(底边 y=17 x16~145;两腿外上翘至 y=7 x7/x158),线宽 2px
(注:MCU 实现中两腿加粗为 3px 以补偿 1bpp 斜线阈值损耗,见 ui_menu.c MENU_DECO_LEG_W) */
.deco { position:absolute; left:0; top:0;
width:calc(160*var(--u)); height:calc(160*var(--u)); display:block; }
/* 状态行:左对齐 x=17,y=20 */
.status { position:absolute; left:calc(17*var(--u)); top:calc(20*var(--u));
font-size:calc(12*var(--u)); line-height:calc(13*var(--u)); white-space:nowrap; }
/* 列表框:左对齐 x=0,宽 99,7 行 × 15px + 1px 边框 = 高 107 */
.list { position:absolute; left:0; top:calc(44*var(--u));
width:calc(99*var(--u)); height:calc(107*var(--u));
box-sizing:border-box; border:calc(1*var(--u)) solid #fff; }
/* 行:高 15;文字左对齐(左留白 8);箭头紧跟文字(x=58) */
.row { position:absolute; left:0; width:100%; height:calc(15*var(--u));
font-size:calc(12*var(--u)); line-height:calc(15*var(--u)); }
.row .lb { position:absolute; left:calc(8*var(--u)); top:0; }
.row .ar { position:absolute; left:calc(58*var(--u)); top:0; }
.row.sel { background:#fff; color:#000; }
.r1 { top:calc(0*var(--u)); }
.r2 { top:calc(15*var(--u)); }
.r3 { top:calc(30*var(--u)); }
.r4 { top:calc(45*var(--u)); }
.r5 { top:calc(60*var(--u)); }
.r6 { top:calc(75*var(--u)); }
.r7 { top:calc(90*var(--u)); }
</style>
</head>
<body>
<!-- ① 1:1 实际大小(160×160,所见即真机) -->
<div class="ui">
<svg class="deco" viewBox="0 0 160 160">
<path d="M 7 7 L 16 17 L 145 17 L 158 7" fill="none" stroke="#fff" stroke-width="2"/>
</svg>
<div class="status">当前模式 : 无模式</div>
<div class="list">
<div class="row r1 sel"><span class="lb">装置信息</span></div>
<div class="row r2"><span class="lb">实时数据</span><span class="ar">▶</span></div>
<div class="row r3"><span class="lb">参数定值</span><span class="ar">▶</span></div>
<div class="row r4"><span class="lb">三遥设置</span><span class="ar">▶</span></div>
<div class="row r5"><span class="lb">装置维护</span><span class="ar">▶</span></div>
<div class="row r6"><span class="lb">通讯参数</span><span class="ar">▶</span></div>
<div class="row r7"><span class="lb">记录查询</span><span class="ar">▶</span></div>
</div>
</div>
<!-- ② 4x 放大(1 设备像素 = 4 屏幕像素,布局与 1:1 完全等价) -->
<div class="ui z4">
<svg class="deco" viewBox="0 0 160 160">
<path d="M 7 7 L 16 17 L 145 17 L 158 7" fill="none" stroke="#fff" stroke-width="2"/>
</svg>
<div class="status">当前模式 : 无模式</div>
<div class="list">
<div class="row r1 sel"><span class="lb">装置信息</span></div>
<div class="row r2"><span class="lb">实时数据</span><span class="ar">▶</span></div>
<div class="row r3"><span class="lb">参数定值</span><span class="ar">▶</span></div>
<div class="row r4"><span class="lb">三遥设置</span><span class="ar">▶</span></div>
<div class="row r5"><span class="lb">装置维护</span><span class="ar">▶</span></div>
<div class="row r6"><span class="lb">通讯参数</span><span class="ar">▶</span></div>
<div class="row r7"><span class="lb">记录查询</span><span class="ar">▶</span></div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,77 @@
# 界面 01:主界面(根页)— 规格说明
> 配套样机:`01_主界面.html`(仅渲染页面本身)。本文件为设计规格(source of truth):布局参数、字符集核对、待确认问题、映射、LVGL 翻译要点。
> 规则:160×160 设备像素 | 1bpp 纯黑白 | SimSun 12px(汉字 12px/字,ASCII 6px/字,行高 13)
> 布局依据:原图 `01_主界面原始图片.png`(628×631 @ 3.925x 缩放)实测 + 渲染目视比对。
---
## 一、布局参数(单位:设备像素)
| 项 | 值 | 说明 |
|---|---|---|
| 顶部装饰 | 开口浅 V,两腿 3px / 水平线 2px | 左尖 (7,7)、底左 (16,17)、底右 (145,17)、右尖 (158,7);底边 y=17,两腿外上翘至 y=7。实现拆 3 段 `lv_line`:两腿加粗(`MENU_DECO_LEG_W`=3)补偿 1bpp 斜线边缘阈值损耗,水平线保持 2px(`MENU_DECO_HORIZ_W`) |
| 状态行 | 左对齐 x=17,顶 y=20 | 行高 13,文案"当前模式 : 无模式"(全角冒号,两侧半角空格) |
| 列表框 | x=0,顶 y=44,宽 99,高 107 | **左对齐**(非通栏),1px 白边框,内 7 行 × 15px(7×15+2 边框=107) |
| 行高 | 15 | 7 行 |
| 行内文字 | 左对齐,左留白 8 | 4 字 = 48px(x 8~56) |
| 箭头 ▶ | x=58(紧跟文字) | 非右对齐;仅第 2~7 行有 |
| 选中行 | 第 1 行整行反色 | 白底黑字,**无箭头**,白色高亮铺满框宽 |
## 二、行内容与状态
| 行 | 文本 | 箭头 | 状态 |
|---|---|---|---|
| 1 | 装置信息 | 无 | **选中**(整行反色:白底黑字) |
| 2 | 实时数据 | ▶(紧跟文字) | 普通(黑底白字) |
| 3 | 参数定值 | ▶(紧跟文字) | 普通 |
| 4 | 三遥设置 | ▶(紧跟文字) | 普通 |
| 5 | 装置维护 | ▶(紧跟文字) | 普通 |
| 6 | 通讯参数 | ▶(紧跟文字) | 普通 |
| 7 | 记录查询 | ▶(紧跟文字) | 普通 |
说明:选中行(第 1 行)无 ▶ 箭头;其余行 ▶ 紧跟在 4 字文本后(非右对齐)。行内文字左对齐、左留白 8px。
## 三、字符集核对(对照现有 ui_font_cn_12:78 汉字 + ASCII 0x20~0x7E)
- **菜单 7 项**共 22 个唯一汉字:命中 21(装 置 信 息 实 时 数 据 参 定 值 遥 设 维 护 通 讯 记 录 查 询),**新增 1(三)**。
- **状态行**"当前模式 : 无模式":**新增 5(当 前 模 式 无)** + **全角冒号 :(U+FF1A,新增)**;空格为 ASCII(已在字库)。
- **箭头**:**▶(U+25B6,新增符号)**。
→ **确定新增 8 个字形**:三 当 前 模 式 无 : ▶。
写 LVGL 代码前必须重新生成字体,否则新增字在真机显示为空白。流程:代码落库 → `python tools/font_gen/extract_cn.py`(自动从 UI 源码字符串提取)→ `python tools/font_gen/make_font.py` → 两端(MCU/模拟器)同时生效。
## 四、待确认问题
> 设计形态(框宽/选中态/装饰形状/冒号)已按原图定形(见第一、二节),不再待确认。以下为**内容/数据**问题,需用户答复。
| # | 问题 | 说明 |
|---|---|---|
| Q1 | 7 个条目 ↔ 子树映射(见第五节表)是否成立? | 其中 3 项疑似重命名(参数定值/三遥设置/通讯参数),需逐项确认 |
| Q2 | "三遥设置"对应现有哪个节点? | 现有 g_menu_tab 有"遥测量"(测量值/遥调系数/遥测系数/双字节/装置遥调);"三遥"是否 = 遥测+遥信+遥控 的新节点? |
| Q3 | "厂家设置 / 版本信息"入口在哪? | 旧菜单中"厂家设置"为独立顶级项(含元件设置/恢复默认/版本显示/版本信息),新 7 项列表未包含——从哪进入? |
| Q4 | "当前模式"指什么、数据从哪来? | 状态行动态值来源(运行/保护/通信模式?);"无模式"为默认?需 App 层供给字符串 |
## 五、条目 → 子树 建议映射(Q1,均待确认;对照现有 g_menu_tab)
| # | 条目 | 现有节点 | 建议映射 |
|---|---|---|---|
| 1 | 装置信息 | 装置信息(L1) | 直接沿用(无变化) |
| 2 | 实时数据 | 实时数据(L1)→ 交流量/直流量/开入量 | 直接沿用(无变化) |
| 3 | 参数定值 | 定值参数(L1)→ 定值/查看 | **重命名** 定值参数 → 参数定值?(待确认) |
| 4 | 三遥设置 | 遥测量(L1)→ 测量值/遥调系数/… | 对应"遥测量"?或新增"三遥"(遥测/遥信/遥控)节点?(Q2) |
| 5 | 装置维护 | 装置维护(L1)→ 时间设置/强制复归/手动录波/自动录波 | 直接沿用(无变化) |
| 6 | 通讯参数 | 通讯设置(L1)→ 通讯参数/网络配置/SNTP设置 | **重命名** 通讯设置 → 通讯参数?或提升 L2"通讯参数"?(待确认) |
| 7 | 记录查询 | 记录查询(L1)→ SOE/事故/操作/告警/… | 直接沿用(无变化) |
注:旧菜单中的"厂家设置"(L0,含 元件设置/恢复默认/版本显示/版本信息/装置遥调/装置记录)未出现在新 7 项列表中,入口待 Q3 确认。
## 六、LVGL 翻译要点(阶段 2 用)
1. 列表 = 持久容器 `lv_obj`(x=0, y=44, 99×107,1px 边框)+ 7 个行子 `lv_obj`(高 15,绝对定位 `lv_obj_set_pos`,固定布局更可控)
2. 行 = `lv_obj` + `lv_label`(静态文本,左对齐 x=8)+ `lv_label`("▶",x=58,紧跟文字);选中行 = `LV_STATE_CHECKED` 整行反色(白底黑字,高亮铺满框宽)
3. 状态行 = `lv_label`(x=17, y=20,文本由 App 层供给,Q4)
4. 顶部装饰 = 开口浅 V(4 段折线:(7,7)→(16,17)→(145,17)→(158,7)):LVGL 可用 3 条 `lv_obj` 线段拼合(斜线用 `lv_line`),或预渲染 1bpp 位图 `lv_image`(更省对象、更精确)
5. 1bpp 禁区(本样机均未使用):圆角 / 阴影 / 透明度 / 渐变 / 多色
6. 内存:根页 = 1 列表容器 + 7 行 ×(行 obj + 2 label)+ 状态 label + 装饰 ≈ 20+ 个对象,为最高频页面,阶段 2 需核算 40KB LVGL 池占用
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+227
View File
@@ -0,0 +1,227 @@
# LVGL PC 模拟器(simulator/)
> 在 PC 上编译运行与 MCU **同一份** UI 源码(`Drivers/BSP/UI/ui_menu.c` + `ui_font_cn_12.c`),
> 实时预览 160×160 1bpp 单色菜单界面,**无需烧录 MCU**。
>
> 部署细节与完整 FAQ 见 `docs/UI 设计/LVGL模拟器部署与使用说明.md`;本文聚焦**本目录的内容与架构**。
---
## 一、目录内容
```
simulator/
├── CMakeLists.txt # 构建脚本(CMake + MSVC,x64)
├── main.c # 入口:SDL 初始化 → LVGL → 菜单 → 主循环
├── sim_port.c / sim_port.h # 模拟器显示/输入端口(SDL 窗口 + 键盘),替代 MCU 端 ui_port.c
├── lv_conf.h # 模拟器专用 LVGL 配置(与 MCU 端 Lib/lv_conf.h 相互独立)
├── i1_to_png.py # 截图转 PNG:把 --screenshot 的 I1(1bpp) 原始位图转成 PNG(见 §3.5)
├── dbg_log.h # 调试日志 stub:DBG_* 映射到 printf(覆盖 MCU 版头文件)
├── SDL2/
│ └── SDL2-2.32.10/ # SDL2 预编译 SDK(随仓库提供,无需安装)
│ ├── include/ # SDL2 头文件
│ │ └── SDL2/ # ★ 头文件副本(LVGL SDL 驱动写死 #include "SDL2/SDL.h")
│ └── lib/
│ ├── x64/ # SDL2.lib / SDL2main.lib / SDL2.dll(构建使用)
│ └── x86/ # 32 位库(当前未使用)
└── build/ # CMake 生成物(可整体删除重建,勿提交)
└── Debug/lvgl_sim.exe # 可执行文件(SDL2.dll 由 POST_BUILD 自动复制到同目录)
```
### 文件职责
| 文件 | 说明 |
|------|------|
| `main.c` | 程序入口。`SDL_Init(TIMER\|EVENTS)` → `lv_init()` → `sim_port_init(160,160)` → `ui_menu_init()`(共享代码)→ 主循环(SDL 事件 + `lv_timer_handler` + `lv_refr_now`) |
| `sim_port.c` | 创建 SDL 窗口显示设备(`lv_sdl_window_create`)、初始化 **mono 主题**(`lv_theme_mono_init`,1bpp 必需)、创建键盘输入设备并挂默认输入组。**不创建鼠标**(纯键盘导航,与 MCU 端 keypad 一致) |
| `lv_conf.h` | 模拟器专用 LVGL 配置。与 MCU 端 `Lib/lv_conf.h` 的关键差异见 §3.3 |
| `dbg_log.h` | MCU 版 `dbg_log.h` 依赖 `sys_clock.h`(RTC 时间戳),PC 无法编译。此 stub 把 `DBG_INFO/ERROR/DEBUG` 映射到 `printf`;因 `simulator/` 在 include 路径最前,**优先生效**覆盖 MCU 版 |
| `CMakeLists.txt` | 收集 LVGL 源码(`../Lib/lvgl`)+ 共享 UI 源码(`../Drivers/BSP/UI`)+ 本目录端口代码,链接预编译 SDL2,产出 `lvgl_sim.exe` |
| `i1_to_png.py` | 把 `--screenshot` 产出的 I1(1bpp) 原始位图转成 PNG,便于离线比对设计稿。详见 §3.5 |
> **共享代码不复制**:`ui_menu.c` / `ui_font_cn_12.c` 直接引用 `Drivers/BSP/UI/` 下的原件,
> MCU 与 PC 两端永远同步。**不要**在本目录内复制 UI 源码。
---
## 二、架构框架
### 2.1 运行架构
```
main.c
│
├─ SDL_Init(TIMER | EVENTS) # 仅初始化计时/事件,视频由 LVGL SDL 驱动内部接管
├─ lv_init() # LVGL v9.3.0 内核
├─ sim_port_init(160, 160)
│ ├─ lv_group_create + set_default # 默认输入组
│ ├─ lv_sdl_window_create(160,160) # SDL 窗口 = 显示设备(I1 单色)
│ ├─ lv_theme_mono_init(disp, true, # mono 主题(白字黑底),1bpp 下不初始化 = 黑屏
│ │ LV_FONT_DEFAULT)
│ └─ lv_sdl_keyboard_create # 键盘 → 默认输入组(方向键/Enter/Esc)
├─ ui_menu_init() # ★ 与 MCU 共用(Drivers/BSP/UI/ui_menu.c)
│ # 基元控件页面导航,节点表驱动
└─ 主循环
├─ SDL_PollEvent # SDL_QUIT → 退出
├─ lv_timer_handler() # 驱动 LVGL 定时器/重绘
├─ lv_refr_now(NULL) # 强制整屏刷新(PARTIAL 模式下保证首帧输出)
└─ Sleep(lv_timer_handler 返回值) # 自适应休眠
```
### 2.2 渲染链路(1bpp 单色)
```
LVGL 场景(基元控件页面/label,LV_COLOR_FORMAT_I1)
→ SW 渲染器(仅启用 I1,LV_DRAW_SW_SUPPORT_I1=1)
→ SDL 窗口 blit(LV_SDL_RENDER_MODE_PARTIAL,1bpp 强制要求)
→ 屏幕(160×160 物理像素,Windows 高 DPI 可能放大显示,属正常)
```
对照 MCU 端链路(`ui_port.c`):
```
LVGL 场景(同一份 ui_menu.c)
→ SW 渲染器(I1)
→ flush 回调 memcpy 到内存帧缓冲 g_ui_fbuf(3200 B)
→ RemoDispBus 协议(TCP 7003)按需推给远端客户端显示
```
两端**场景层完全相同**,只有"渲染结果去向"不同:模拟器进 SDL 窗口,MCU 进内存帧缓冲再走网络。
### 2.3 MCU 端 vs 模拟器 对照
| 项 | MCU(Keil MDK) | 模拟器(CMake + MSVC) |
|----|----------------|------------------------|
| LVGL 编译 | `.uvprojx` 工程添加 `Lib/lvgl` 源文件 | `Lib/lvgl/CMakeLists.txt`(最小 CMake,**仅模拟器用**,不影响 Keil 编译) |
| lv_conf.h | `Lib/lv_conf.h` | `simulator/lv_conf.h`(独立,互不影响) |
| 显示端口 | `ui_port.c`(离屏 framebuffer + flush 回调) | `sim_port.c`(SDL 窗口) |
| 输入 | 远端 keypad(`ui_port_queue_key`,网络回传键码) | SDL 键盘(`lv_sdl_keyboard_create`) |
| 主题 | 无主题(`LV_USE_THEME_* = 0`),控件用内置基础样式 | **mono 主题必须开启**(否则 1bpp 黑底黑字) |
| 日志 | `Drivers/BSP/dbg_log.h`(USART1 + RTC 时间戳) | `simulator/dbg_log.h`(printf stub) |
| 业务任务 | netTask / ftpTask / timeSyncTask 等 | 无,纯 UI |
| 时钟 | `lv_tick_inc` 由 ui_task 手动喂(10ms 周期) | SDL 驱动内部 `lv_tick_set_cb(SDL_GetTicks)`,**不可再手动喂** |
### 2.4 关键设计决策
1. **SDL2**:LVGL 官方 PC 端口同款显示方案;使用仓库内预编译 SDK(`SDL2/SDL2-2.32.10/`),无需系统安装
2. **CMake + MSVC**:`Visual Studio 16 2019` 生成器,`-A x64`;MSVC 下加 `/utf-8` 消除中文注释的 C4819 警告
3. **共享 UI 源码**:`ui_menu.c` / `ui_font_cn_12.c` 直接编译 `Drivers/BSP/UI/` 原件,改一处两端同时生效
4. **纯键盘导航**:不创建鼠标/触摸设备,与 MCU 端 keypad 操作语义一致(↑↓←→ / Enter / Esc)
5. **仅 UI 预览**:不含网络/存储/ADC 等硬件业务,无法验证与外设相关的逻辑
---
## 三、构建与运行
### 3.1 环境要求
| 依赖 | 版本 | 说明 |
|------|------|------|
| CMake | ≥ 3.15 | 本机验证 4.3.0-rc1 |
| MSVC | VS2019 BuildTools | `Visual Studio 16 2019` 生成器,Hostx64/x64 |
| SDL2 | 2.32.10 | 已内置于 `SDL2/`,无需安装 |
| 操作系统 | Windows | 已验证 win32 |
### 3.2 构建命令
```powershell
# 首次(配置 + 构建)
cd simulator
cmake -B build -G "Visual Studio 16 2019" -A x64
cmake --build build --config Debug
# 日常(仅改 .c/.h 时增量编译)
cmake --build build --config Debug
# 运行
.\build\Debug\lvgl_sim.exe
# 或:cmake --build build --config Debug --target run
```
> 改 `CMakeLists.txt` 后需重新 `cmake -B build`;遇到奇怪问题先删 `build/` 全量重建。
### 3.3 `lv_conf.h` 与 MCU 端的关键差异
| 配置 | MCU 端(`Lib/lv_conf.h`) | 模拟器(本目录) | 原因 |
|------|--------------------------|------------------|------|
| `LV_USE_SDL` | 0 | **1** | SDL 显示/键盘驱动 |
| `LV_SDL_RENDER_MODE` | — | **`PARTIAL`** | 1bpp + SDL 非 PARTIAL 会 `#error` |
| `LV_SDL_INCLUDE_PATH` | — | `"SDL2/SDL.h"` | 对应 `include/SDL2/` 头文件副本 |
| `LV_USE_THEME_MONO` | 0 | **1** | 1bpp 无主题 = 黑底黑字不可见 |
| `LV_DRAW_SW_SUPPORT_I1` | 1 | 1 | 仅启用 I1 渲染,裁掉其余色深 |
| `LV_MEM_SIZE` | 40 KB | 40 KB | 保持一致(PC 内存充足,可放宽) |
| `LV_FONT_DEFAULT` | `&ui_font_cn_12` | 同 | 共用同一字体 |
| 其余控件/驱动 | 裁剪 | 基本一致(另开 `LV_USE_TEXTAREA/KEYBOARD/BUTTONMATRIX`) | 模拟器略宽松,便于调试 |
### 3.4 键盘操作
| 按键 | 功能 |
|------|------|
| ↑ / ↓ | 菜单项上下移动 |
| → | 进入子页 |
| Enter | 确认 / 进入子页 |
| ← / Escape | 返回上级 |
### 3.5 截图比对设计稿(离线出图)
窗口模式只适合人工目检。要把渲染结果存成 PNG 与设计稿(`docs/UI 设计/mockups/render_01.png`)逐像素比对,用离屏截图 + 转 PNG:
```powershell
cd simulator
# 1) 离屏渲染首帧,输出 I1(1bpp) 原始位图(已剥离 8 字节调色板,纯像素数据)
.\build\Debug\lvgl_sim.exe --screenshot shot.bin
# 2) 转 PNG(默认 160x160;--scale 放大便于查看)
python i1_to_png.py shot.bin # -> shot.png
python i1_to_png.py shot.bin -o 预览.png --scale 6
```
**I1 位图格式**(`i1_to_png.py` 据此解析):1 位/像素,每字节 MSB 在左(bit7 = 该行最左像素),`bit=1` 前景 / `bit=0` 背景,每行 `宽/8` 字节、行内无填充。
`i1_to_png.py` 常用参数:
| 参数 | 说明 |
|------|------|
| `-o, --output` | 输出 PNG 路径(默认与输入同名 `.png`) |
| `-W, -H` | 宽高(默认 160/160,非默认分辨率时必填) |
| `--scale N` | 最近邻放大 N 倍,便于查看小图 |
| `--invert` | 前/背景互换 |
| `--fg / --bg` | 自定义前/背景色 `R,G,B`(默认黑白) |
| `--no-auto-palette` | 关闭调色板自动识别(按纯像素解析) |
> 依赖 Pillow(`pip install pillow`,本仓库已验证)。`shot.bin`/`shot*.png` 均已列入 `.gitignore`,可随时删除重生成。
---
## 四、已知坑(构建/运行)
1. **`cannot open include file "SDL2/SDL.h"`**
LVGL SDL 驱动写死 `#include "SDL2/SDL.h"`,而原版 SDK 头文件平铺在 `include/` 下。
必须把头文件复制一份到 `include/SDL2/` 子目录(本仓库已做好):
```powershell
$inc = "simulator\SDL2\SDL2-2.32.10\include"
New-Item -ItemType Directory -Path "$inc\SDL2" -Force
Copy-Item "$inc\*.h" "$inc\SDL2\"
```
2. **`cannot open include file "lv_conf.h"`**
`LV_CONF_DIR` / `SDL2_INCLUDE_DIR` 两个 CMake 变量必须在 `add_subdirectory(lvgl)` **之前**定义(见 `CMakeLists.txt` 中的顺序注释),勿随意调整。
3. **运行后窗口全黑**,按序排查:
- `LV_USE_THEME_MONO = 1` 且 `sim_port_init` 中调用了 `lv_theme_mono_init`
- 主循环调用了 `lv_refr_now(NULL)`(PARTIAL 模式保证首帧)
- 没有重复设置 tick(SDL 驱动内部已 `lv_tick_set_cb`,**不要**再 `SDL_AddTimer` + `lv_tick_inc`)
4. **中文显示空白**:字体 `ui_font_cn_12` 是子集字体(当前 78 字),缺字渲染为空白。
补字流程见 `docs/UI 设计/UI_中文字体生成与配置.md`(`tools/font_gen/` 两步脚本)。
5. **改了 UI 代码但模拟器没变化**:确认改的是 `Drivers/BSP/UI/ui_menu.c`(共享原件)而非本目录副本(本目录无副本),然后重新 `cmake --build`。
6. **编码**:UI 源码为 UTF-8(带 BOM),MSVC 已加 `/utf-8`。**不要**用 PowerShell `Set-Content` 等重写含中文的文件(编码铁律,见根目录 `AGENTS.md`)。
7. **键盘输入无响应(方向键/Enter 无效)**:`main.c` 主循环**不得**调用 `SDL_PollEvent`。
LVGL SDL 驱动内部有 5ms 定时器(`lv_sdl_window.c` 的 `sdl_event_handler`)统一轮询分发 SDL 事件;
主循环若先轮询会消费掉事件队列,键盘输入永远到不了 LVGL(现象:画面正常但按键全部无效)。
关窗退出由驱动 `LV_SDL_DIRECT_EXIT=1` 自动 `exit(0)`,主循环无需处理 SDL_QUIT。
---
## 五、边界说明
- 模拟器**只跑 UI**:不含 CH395F 网络、NAND 存储、ADC、RS-485 等硬件业务,
也**不含** `ui_net_sink`(推流服务端)——远端推流链路只能在 MCU 上验证
- `build/` 为生成物,可随时删除重建(已在 `.gitignore` 中忽略)
- `Lib/lvgl/CMakeLists.txt` 仅被本模拟器引用,**不影响** Keil 的 MCU 编译
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
i1_to_png.py — 将 LVGL 模拟器 `--screenshot` 产出的 I1(1bpp) 原始位图转换为 PNG。
输入格式(模拟器 main.c 的 --screenshot 模式输出):
- 1 位/像素,每字节 MSB 在左(bit7=该行最左像素),bit=1 为前景、bit=0 为背景
- 每行 stride = 宽 / 8 字节,行内无填充
- 模拟器当前已剥离 8 字节调色板,文件即纯像素数据(宽/8 × 高 字节)
- 若遇到仍含调色板的旧文件(像素数据 + 8 字节),--auto-palette 会自动识别并跳过
用法示例:
python i1_to_png.py shot.bin # -> shot.png(默认 160x160)
python i1_to_png.py shot.bin -o out.png # 指定输出
python i1_to_png.py shot.bin --scale 6 # 放大 6 倍便于查看(最近邻,保持像素感)
python i1_to_png.py shot.bin -W 320 -H 240 # 非默认分辨率
python i1_to_png.py shot.bin --fg 0,0,0 --bg 255,255,255 # 自定义前/背景色(RGB 模式)
python i1_to_png.py shot.bin --invert # 前景/背景互换
依赖:Pillow(pip install pillow)。本仓库环境已验证 PIL 12.2.0。
退出码:0 成功;1 参数/数据错误。
"""
import argparse
import os
import sys
# DTU 屏幕默认分辨率(160x160,1bpp)
DEFAULT_W = 160
DEFAULT_H = 160
# I1 调色板大小(2 色 × 4 字节)。模拟器新版已剥离,旧文件可能残留。
I1_PALETTE_BYTES = 2 * 4
def _decode_i1(data, width, height):
"""把原始 I1 数据解成 (height, width) 的 0/1 矩阵。MSB 在左。"""
stride = width // 8
if width % 8 != 0:
raise ValueError("宽度必须是 8 的整数倍(I1 按字节打包),当前 %d" % width)
need = stride * height
if len(data) < need:
raise ValueError("数据不足:%d 字节 < 需要 %d 字节(%dx%d I1)"
% (len(data), need, width, height))
px = []
for y in range(height):
base = y * stride
row = [0] * width
for x in range(width):
byte_i = x >> 3
bit_i = 7 - (x & 7)
row[x] = (data[base + byte_i] >> bit_i) & 1
px.append(row)
return px
def _pick_palette_offset(data, width, height, auto):
"""返回像素数据在文件中的起始偏移(跳过调色板)。"""
stride = width // 8
need = stride * height
sizes = [len(data)]
if auto:
# 像素数据前可能带 I1 调色板(8 字节)
if len(data) == need + I1_PALETTE_BYTES:
return I1_PALETTE_BYTES
if len(data) < need:
raise ValueError("数据不足:%d 字节 < 像素所需 %d 字节" % (len(data), need))
return 0
def i1_to_png(input_path, output_path=None, width=DEFAULT_W, height=DEFAULT_H,
scale=1, invert=False, auto_palette=True,
fg=None, bg=None):
"""核心转换函数,可被其他脚本直接 import 调用。
fg / bg 形如 (r,g,b);为 None 时用黑白(L 模式)。
返回输出文件路径。
"""
from PIL import Image
if not os.path.isfile(input_path):
raise FileNotFoundError(input_path)
with open(input_path, "rb") as f:
data = f.read()
off = _pick_palette_offset(data, width, height, auto_palette)
px = _decode_i1(data[off:], width, height)
nw, nh = width, height
if fg is None and bg is None:
# 灰度:bit=1 -> 白(255),bit=0 -> 黑(0)
img = Image.new("L", (nw, nh))
ipx = img.load()
for y in range(nh):
for x in range(nw):
bit = px[y][x]
if invert:
bit ^= 1
ipx[x, y] = 255 if bit else 0
else:
fgc = fg if fg is not None else (255, 255, 255)
bgc = bg if bg is not None else (0, 0, 0)
if invert:
fgc, bgc = bgc, fgc
img = Image.new("RGB", (nw, nh), bgc)
ipx = img.load()
for y in range(nh):
for x in range(nw):
bit = px[y][x]
ipx[x, y] = fgc if bit else bgc
# 放大(最近邻,保持 1bpp 像素感)
if scale and scale > 1:
img = img.resize((nw * scale, nh * scale), Image.NEAREST)
if output_path is None:
output_path = os.path.splitext(input_path)[0] + ".png"
img.save(output_path, "PNG")
return output_path
def _parse_rgb(s):
if s is None:
return None
parts = s.split(",")
if len(parts) != 3:
raise argparse.ArgumentTypeError("颜色格式应为 R,G,B,如 255,0,0")
try:
r, g, b = (int(p) for p in parts)
except ValueError:
raise argparse.ArgumentTypeError("颜色分量必须是整数")
for v in (r, g, b):
if not (0 <= v <= 255):
raise argparse.ArgumentTypeError("颜色分量需在 0~255")
return (r, g, b)
def _ensure_utf8_console():
"""Windows 控制台默认 GBK,中文会乱码;统一切到 UTF-8(不支持则静默跳过)。"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass
def main(argv=None):
_ensure_utf8_console()
ap = argparse.ArgumentParser(
description="将模拟器 I1(1bpp) 原始截图转换为 PNG",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
ap.add_argument("input", help="I1 原始位图文件(.bin)")
ap.add_argument("-o", "--output", default=None,
help="输出 PNG 路径(默认与输入同名 .png)")
ap.add_argument("-W", "--width", type=int, default=DEFAULT_W, help="图像宽度")
ap.add_argument("-H", "--height", type=int, default=DEFAULT_H, help="图像高度")
ap.add_argument("--scale", type=int, default=1,
help="放大倍数(最近邻,便于查看小图)")
ap.add_argument("--invert", action="store_true", help="前景/背景互换")
ap.add_argument("--no-auto-palette", action="store_true",
help="不做调色板自动识别(按纯像素数据解析)")
ap.add_argument("--fg", type=_parse_rgb, default=None, help="前景色 R,G,B(bit=1)")
ap.add_argument("--bg", type=_parse_rgb, default=None, help="背景色 R,G,B(bit=0)")
args = ap.parse_args(argv)
try:
out = i1_to_png(
args.input,
output_path=args.output,
width=args.width,
height=args.height,
scale=args.scale,
invert=args.invert,
auto_palette=not args.no_auto_palette,
fg=args.fg,
bg=args.bg,
)
except (ValueError, FileNotFoundError) as e:
print("错误:%s" % e, file=sys.stderr)
return 1
print("已生成:%s" % out)
return 0
if __name__ == "__main__":
sys.exit(main())
+7 -6
View File
@@ -49,11 +49,11 @@
#define LV_DRAW_BUF_STRIDE_ALIGN 1
#define LV_DRAW_BUF_ALIGN 4
/** 关闭复杂绘制,省代码 */
#define LV_DRAW_SW_COMPLEX 0
/** 复杂绘制:主界面顶部开口浅 V 装饰含斜线(lv_line 斜段依赖 LV_DRAW_SW_COMPLEX),须与 MCU 端一致保持开启,否则两腿不渲染 */
#define LV_DRAW_SW_COMPLEX 1
#define LV_DRAW_SW_COMPLEX_GRADIENTS 0
#define LV_DRAW_SW_SHADOW_CACHE_SIZE 0
#define LV_DRAW_SW_CIRCLE_CACHE_SIZE 0
#define LV_DRAW_SW_CIRCLE_CACHE_SIZE 4 /* LV_DRAW_SW_COMPLEX 依赖非零(零长数组 C2466);与 MCU 端默认一致 */
#define LV_DRAW_LAYER_SIMPLE_BUF_SIZE 0
#define LV_DRAW_LAYER_MAX_MEMORY 0
@@ -87,7 +87,8 @@
#define LV_USE_THORVG 0
#define LV_USE_VECTOR_GRAPHIC 0
#define LV_USE_MATRIX 0
#define LV_USE_SNAPSHOT 0
/* 离屏截图(--screenshot)用 lv_snapshot 取整屏,需要开启 */
#define LV_USE_SNAPSHOT 1
/*============================================================================
* INPUT DEVICES
@@ -229,10 +230,10 @@
#define LV_LABEL_LONG_TXT_HINT 0
#define LV_LABEL_WAIT_CHAR_COUNT 0
#define LV_USE_LED 0
#define LV_USE_LINE 0
#define LV_USE_LINE 1
#define LV_USE_LIST 0
#define LV_USE_LOTTIE 0
#define LV_USE_MENU 1
#define LV_USE_MENU 0
#define LV_USE_MSGBOX 0
#define LV_USE_QRCODE 0
#define LV_USE_RLOTTIE 0
+104 -17
View File
@@ -7,10 +7,16 @@
* ↑/↓/←/→ → 方向键导航
* Enter → 确认/进入
* Escape → 返回
*
* 截图模式(无窗口离屏渲染,导出像素供比对):
* lvgl_sim.exe --screenshot shot.bin
* 走与 MCU 端一致的离屏软件渲染(ARGB8888 缓冲 + mono 主题),
* 渲染首帧后将 160x160 像素按 BGRA 顺序写入 shot.bin(102400 字节),随即退出。
*/
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#ifdef _MSC_VER
#include <Windows.h>
@@ -29,13 +35,104 @@
#define SIM_SCREEN_W 160
#define SIM_SCREEN_H 160
/* 离屏截图缓冲:I1(1bpp,与 MCU/模拟器窗口一致;软件渲染器仅支持 I1),160x160/8 */
static uint8_t g_shot_buf[SIM_SCREEN_W * SIM_SCREEN_H / 8];
/*
* @brief 离屏截图用空 delay 回调
* @note 默认 lv_delay_ms 会忙等 tick 前进,但离屏渲染时 tick 不会在等待期间推进,
* 导致死循环挂死。window 模式经 SDL 设了真实 delay,离屏这里用空实现代替。
*/
static void shot_delay_cb(uint32_t ms)
{
(void)ms;
}
/*
* @brief 离屏截图 flush 回调(不写真实设备,直接标记就绪)
*/
static void shot_flush_cb(lv_display_t * disp, const lv_area_t * area, uint8_t * color_p)
{
(void)area;
(void)color_p;
lv_display_flush_ready(disp);
}
/*
* @brief 无窗口离屏渲染并导出首帧像素
* @param path 输出文件(写入 160x160 BGRA 原始像素)
* @return 0 成功,非 0 失败
*/
static int run_screenshot(const char * path)
{
setvbuf(stdout, NULL, _IONBF, 0);
lv_init();
lv_delay_set_cb(shot_delay_cb);
lv_display_t *disp = lv_display_create(SIM_SCREEN_W, SIM_SCREEN_H);
if (disp == NULL) {
return 1;
}
lv_display_set_default(disp);
lv_display_set_color_format(disp, LV_COLOR_FORMAT_I1);
lv_display_set_buffers(disp, g_shot_buf, NULL, sizeof(g_shot_buf),
LV_DISPLAY_RENDER_MODE_FULL);
lv_display_set_flush_cb(disp, shot_flush_cb);
lv_group_t *g = lv_group_create();
if (g == NULL) {
return 1;
}
lv_group_set_default(g);
#if LV_USE_THEME_MONO
lv_theme_t *th = lv_theme_mono_init(disp, true, LV_FONT_DEFAULT);
if (th != NULL) {
lv_display_set_theme(disp, th);
}
#endif
if (ui_menu_init() != 0) {
return 1;
}
/* 用 snapshot 把整屏渲进独立 I1 draw_buf(避开 I1+FULL 的渲染死循环) */
lv_draw_buf_t *snap = lv_snapshot_take(lv_screen_active(), LV_COLOR_FORMAT_I1);
if (snap == NULL) {
fprintf(stderr, "[shot] snapshot failed\n");
return 1;
}
uint32_t snap_w = snap->header.w;
uint32_t snap_h = snap->header.h;
/* I1 缓冲区布局:[palette 8B][pixel data],跳过 palette 只写像素 */
uint32_t pal_bytes = LV_COLOR_INDEXED_PALETTE_SIZE(LV_COLOR_FORMAT_I1) * (uint32_t)sizeof(lv_color32_t);
uint8_t * px = snap->data + pal_bytes;
uint32_t px_bytes = (uint32_t)snap->header.stride * (uint32_t)snap_h;
FILE *fp = fopen(path, "wb");
if (fp == NULL) {
lv_draw_buf_destroy(snap);
fprintf(stderr, "fopen failed: %s\n", path);
return 1;
}
size_t n = fwrite(px, 1, px_bytes, fp);
fclose(fp);
lv_draw_buf_destroy(snap);
printf("screenshot: %s (%zu bytes, %ux%u I1)\n", path, n, snap_w, snap_h);
return 0;
}
/**
* @brief 程序入口
*/
int main(int argc, char **argv)
{
(void)argc;
(void)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]);
}
}
/* 初始化 SDL2(仅 timer + events,视频由 lv_sdl_window 内部初始化) */
if (SDL_Init(SDL_INIT_TIMER | SDL_INIT_EVENTS) != 0) {
@@ -71,22 +168,12 @@ int main(int argc, char **argv)
printf("Keyboard: ↑↓←→ navigate, Enter confirm, Escape back\n");
printf("Press Ctrl+C or close window to exit\n\n");
/* 主循环:处理 SDL 事件 + 驱动 LVGL 刷新 */
/* 主循环:驱动 LVGL 定时器与刷新
* 注意:SDL 事件(键盘/窗口/退出)由 LVGL SDL 驱动内部 5ms 定时器
* (lv_sdl_window.c 的 sdl_event_handler)统一轮询分发,
* 本循环不得再调 SDL_PollEvent,否则会先消费事件队列,
* 导致键盘输入永远到不了 LVGL */
while (1) {
/* 处理 SDL 事件(窗口关闭等) */
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) {
printf("SDL_QUIT received, exiting...\n");
SDL_Quit();
return 0;
}
if (event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_ESCAPE) {
/* Escape 键:如果在子页面则返回上一级 */
/* LVGL keyboard 驱动已自动处理 LV_KEY_ESC */
}
}
/* 驱动 LVGL 定时器与刷新 */
uint32_t sleep_ms = lv_timer_handler();
if (sleep_ms == LV_NO_TIMER_READY) {
+52
View File
@@ -59,6 +59,57 @@ def resolve_font(path):
sys.exit('ERROR: 未找到 C:\\Windows\\Fonts\\simsun.ttc,请用 --font 指定字体 ttf/otf 路径')
# ---------------------------------------------------------------------------
# 箭头字形合并
# 宋体(simsun.ttc)没有 U+25B6(▶)字形,lv_font_conv 会静默跳过 → 真机显示成方框。
# 该字形从 Segoe UI Symbol(seguisym.ttf,Windows 自带)提取,全量重生成字体后
# 自动补回,避免"重生成字体箭头又变方框"。
# ---------------------------------------------------------------------------
ARROW_CP_DEC = 9654 # U+25B6 ▶
ARROW_BITMAP = [0x80, 0xe0, 0xf0, 0xfc, 0xff, 0xfe, 0xf8, 0xf0, 0xc0, 0x0]
ARROW_ADV_W, ARROW_BOX_W, ARROW_BOX_H, ARROW_OFS_X, ARROW_OFS_Y = 165, 8, 10, 1, -1
def merge_arrow_glyph(font_path):
"""若字体缺 U+25B6(▶),把预提取的箭头字形合并进去(位图 + glyph_dsc + cmap)。"""
import re
raw = open(font_path, 'rb').read()
has_bom = raw.startswith(b'\xef\xbb\xbf')
src = raw.decode('utf-8-sig')
if re.search(r'\.range_start\s*=\s*9654\s*,', src):
print('arrow U+25B6 已存在,跳过合并')
return
# 当前位图总字节数(新字形 bitmap_index 的取值)
bm = re.search(r'glyph_bitmap\[\]\s*=\s*\{', src)
end_bm = src.find('\n};', bm.end())
body_bm = re.sub(r'/\*.*?\*/', '', src[bm.end():end_bm], flags=re.S)
total = len(re.findall(r'0x[0-9a-fA-F]+', body_bm))
glyph_count = len(re.findall(r'\.bitmap_index\s*=\s*\d+', src))
# 1) 追加位图
bmp = ',\n\n /* U+25B6 "▶"(来源:Segoe UI Symbol;宋体无此字形) */\n ' + \
', '.join('0x%02x' % b for b in ARROW_BITMAP)
src = src[:end_bm] + bmp + src[end_bm:]
# 2) 追加 glyph_dsc
gd = re.search(r'glyph_dsc\[\]\s*=\s*\{', src)
end_gd = src.find('\n};', gd.end())
entry = ',\n {.bitmap_index = %d, .adv_w = %d, .box_w = %d, .box_h = %d, .ofs_x = %d, .ofs_y = %d} /* U+25B6 */' % (
total, ARROW_ADV_W, ARROW_BOX_W, ARROW_BOX_H, ARROW_OFS_X, ARROW_OFS_Y)
src = src[:end_gd] + entry + src[end_gd:]
# 3) 追加 cmap(单码位映射)
cm = re.search(r'cmaps\[\]\s*=\s*\{', src)
end_cm = src.find('\n};', cm.end())
cmap = ',\n {\n .range_start = %d, .range_length = 1, .glyph_id_start = %d,\n' \
' .unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY\n }' % (
ARROW_CP_DEC, glyph_count)
src = src[:end_cm] + cmap + src[end_cm:]
# 4) cmap_num +1
src = re.sub(r'\.cmap_num = (\d+),',
lambda m: '.cmap_num = %d,' % (int(m.group(1)) + 1), src, count=1)
out = b'\xef\xbb\xbf' if has_bom else b''
open(font_path, 'wb').write(out + src.encode('utf-8'))
print('已合并箭头 U+25B6:位图+%d 字节,glyph_id=%d' % (len(ARROW_BITMAP), glyph_count))
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--bpp', type=int, default=1, help='字体位深 1/2/3(默认 1)')
@@ -86,6 +137,7 @@ def main():
r = subprocess.run(cmd)
if r.returncode != 0:
sys.exit('FAILED rc=%d' % r.returncode)
merge_arrow_glyph(OUT) # 补回宋体缺失的 ▶ 字形(幂等)
print('OK %d bytes -> %s' % (os.path.getsize(OUT), OUT))
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""
render_mockup.py — 把 mockup HTML 的 1:1 视图渲染成 160×160 PNG(无头 Chrome/Edge)
用法:
python tools/ui_mock/render_mockup.py <mockup.html> <out.png> [--pad 24] [--size 160]
原理:
mockup 的 body 有 padding(默认 24px),1:1 视图是第一个 flex 子项,位于 (pad, pad),
尺寸 160×160。用无头浏览器截整页,再裁出 (pad, pad, pad+160, pad+160) 即得纯页面位图。
浏览器抗锯齿会在边缘产生灰阶;对比前由 compare_mockup.py 统一二值化(阈值 128)。
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
from PIL import Image
import numpy as np
BROWSER_CANDIDATES = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
]
def find_browser():
for c in BROWSER_CANDIDATES:
if os.path.exists(c):
return c
sys.exit("未找到 Chrome/Edge(用于无头渲染)")
def render(html_path, out_png, pad=24, size=160):
browser = find_browser()
html_abs = Path(html_path).resolve()
if not html_abs.exists():
sys.exit("找不到 HTML: %s" % html_abs)
out_abs = Path(out_png).resolve()
out_abs.parent.mkdir(parents=True, exist_ok=True)
win = pad + size + pad + 12 # 留一点余量
cmd = [
browser,
"--headless",
"--disable-gpu",
"--hide-scrollbars",
"--force-device-scale-factor=1",
"--window-size=%d,%d" % (win, win),
"--screenshot=%s" % out_abs,
html_abs.as_uri(),
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
if not out_abs.exists():
sys.exit("截图失败:\n%s\n%s" % (r.stdout, r.stderr))
img = Image.open(out_abs).convert("L")
box = (pad, pad, pad + size, pad + size)
page = img.crop(box)
arr = np.array(page)
dark = float((arr < 128).mean())
page.save(out_abs)
print("OK %s -> %s (%dx%d, 黑像素 %.1f%%)" % (html_abs, out_abs, size, size, dark * 100))
return out_abs
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("html")
ap.add_argument("out")
ap.add_argument("--pad", type=int, default=24, help="mockup body padding(1:1 视图左上偏移)")
ap.add_argument("--size", type=int, default=160)
a = ap.parse_args()
render(a.html, a.out, pad=a.pad, size=a.size)
+1 -1
View File
@@ -1 +1 @@
主事交件位作信值元入全出制功动占厂压参双变号告复字定实家开强归录恢息手护据操故数时显未本查检波流测版现电界直看示系络维网置能自节装警认讯记设询调通遥部配量间零面默()
▶三主事交件位作信值元入全出制前动厂压参双变号告回复字定实家开式强归当录恢息手护据操故数无时显本查检模波流测版电界直看示系络维网置自节装警认讯记设询调返通遥部配量间零面默: