/* * 模块名称:UI Content Page (内容页面显示层) * 模块功能:菜单叶子节点的内容页(装置信息/交流量/直流量)——独立 LVGL 屏幕, * 全屏容器 DRAW_MAIN 自绘全部内容(浅 V 装饰 + 居中标题 + 行数据 + * 页脚页码),无子对象;进入时切屏并获焦、ESC 返回菜单屏并还原焦点 * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) * 作者:王建锋 * 创建日期:2026-09-09 * 修改记录: * 2026-09-09 王建锋 创建:装置信息/交流量/直流量三页(参考图 10/20/21 实测几何) */ /* 头文件包含区 */ #include #include "ui_content_page.h" #include "ui_menu_view.h" #include "lvgl.h" #define DBG_TAG "[UI_CONTENT_PAGE]" #include "dbg_log.h" /* 私有宏定义区 */ /* * 布局常量(设备像素;参考图 10/20/21 实测,3x 放大图缩回 160x160 测量): * 标题 y=2(CJK 字形顶距 +1 → 墨迹自 y=3 起,与参考图一致)、页脚 y=143; * 信息页行:首行 y=40、行距 15、标签列 x=6 / 值列 x=86(无选中带); * 列表页行:槽顶 = 24+14i、选中白带 x=2..156 高 13、名 x=7 / 值 x=101; * 字形顶距(CJK +1 / ASCII +2)已计入放置 y,墨迹与参考图对齐 */ #define CP_SCREEN_W 160 /* 屏幕宽(px,与 ui_port.h / 模拟器一致) */ #define CP_SCREEN_H 160 /* 屏幕高(px) */ #define CP_TITLE_Y 2 /* 标题放置 y(居中;墨迹自 y=3 起) */ #define CP_PAGE_LINE_Y 143 /* 页脚"第 X 页,共 Y 页"放置 y(x=6 左对齐) */ #define CP_DECO_LEG_W 3 /* "浅 V"两腿线宽(与菜单根框一致:1bpp 斜线须加粗) */ #define CP_DECO_HORIZ_W 2 /* "浅 V"水平线宽 */ /* 私有类型定义区 */ /* 行数据:左列名 + 右列值(Flash 字面量,不 strdup) */ typedef struct { const char *name; const char *value; } cp_row_t; /* * 页面定义(Flash 常量表):标题/行几何/数据表/页脚参数; * selectable=1 为可移选分页的数据列表页,0 为静态信息页 */ typedef struct { const char *title; /* 标题(浅 V 区居中) */ uint8_t selectable; /* 1 = 数据列表页(↑↓移选/分页);0 = 信息页(静态行) */ int32_t top_y; /* 首行槽顶 y */ int32_t pitch; /* 行距(相邻行槽顶间距) */ int32_t band_h; /* 选中白带高(0 = 无选中带) */ int32_t text_dy; /* 文本放置相对行槽顶的垂直偏移 */ int32_t left_x; /* 左列 x(信息页=标签、列表页=名) */ int32_t right_x; /* 右列 x(值) */ uint8_t rows_per_page; /* 每页可见行数(0 = 不分页) */ uint16_t total_rows; /* 数据总行数 */ const cp_row_t *rows; /* 数据表(Flash) */ const char *subtitle; /* 副标题(居中;NULL = 无,仅信息页有) */ int32_t subtitle_y; /* 副标题放置 y(-1 = 无) */ uint8_t fixed_cur_page; /* 页脚"第 X 页"固定显示值(信息页按参考图原样) */ uint8_t fixed_total_pages; /* 页脚"共 Y 页"固定显示值 */ } cp_def_t; /* 静态变量定义区 */ /* "浅 V"装饰点集与线宽:几何与菜单根框一致(ui_menu_view.c s_deco_*),culling 区同范围 */ static const lv_point_precise_t s_deco_leg_l[2] = { {7, 7}, {16, 17} }; /* 左腿 */ static const lv_point_precise_t s_deco_hoz[2] = { {16, 17}, {145, 17} }; /* 水平线:接两腿底端 */ static const lv_point_precise_t s_deco_leg_r[2] = { {158, 7}, {145, 17} }; /* 右腿 */ static const lv_point_precise_t *const s_deco_segs[3] = {s_deco_leg_l, s_deco_leg_r, s_deco_hoz}; static const int32_t s_deco_seg_w[3] = {CP_DECO_LEG_W, CP_DECO_LEG_W, CP_DECO_HORIZ_W}; static const lv_area_t s_deco_band = {0, 0, CP_SCREEN_W - 1, 19}; /* 浅 V culling 区 */ /* 装置信息行(参考图 10;标签含全角冒号,值列固定 x=86) */ static const cp_row_t s_info_rows[] = { {"终端类型:", "F30"}, {"终端型号:", "F30"}, {"软件版本:", "SV0.010"}, {"硬件版本:", "HW0.010"}, {"软件校验:", "4454"}, {"程序日期:", "2024.08.27"}, }; /* 交流量行(参考图 20 = 第 1 页原样;第 2/3 页为占位数据,可替换) */ static const cp_row_t s_ac_rows[] = { {"Ia1", "0.000"}, /* 第 1 页(参考图 20) */ {"Ib1", "0.137"}, {"Ic1", "0.274"}, {"Ia2", "0.411"}, {"Ib2", "0.548"}, {"Ic2", "0.685"}, {"Ua1", "0.82"}, {"Ub1", "0.96"}, {"Uc1", "1.03"}, /* 第 2 页(占位数据) */ {"Uab", "1.17"}, {"Ubc", "1.24"}, {"Uca", "1.31"}, {"Hz", "50.00"}, {"P", "1.42"}, {"Q", "0.87"}, {"S", "1.63"}, {"PF", "0.95"}, /* 第 3 页(占位数据) */ {"Iavg","0.35"}, {"Uavg","0.87"}, {"Fmax","50.02"}, {"Imax","1.24"}, {"Pmax","2.10"}, {"Qmax","1.05"}, {"Smax","2.45"}, }; /* 直流量行(参考图 21) */ static const cp_row_t s_dc_rows[] = { {"ZL01", "3.01"}, {"ZL02", "3.15"}, {"Pa1", "3.288"}, }; /* 页面定义表(顺序与 content_page_id_t 一致) */ static const cp_def_t s_defs[CONTENT_PAGE_COUNT] = { /* 装置信息:静态键值行 + 副标题;页脚按参考图原样"第 10 页,共 1 页" */ {"装置信息", 0u, 40, 15, 0, 0, 6, 86, 0u, (uint16_t)6, s_info_rows, "馈线自动化终端", 25, 10u, 1u}, /* 交流量:24 项 / 8 项每页(3 页);text_dy=0:ASCII 字形顶距 +2,墨迹落槽顶+2(与参考图一致) */ {"交流量", 1u, 24, 14, 13, 0, 7, 101, 8u, (uint16_t)24, s_ac_rows, NULL, -1, 0u, 0u}, /* 直流量:3 项单页 */ {"直流量", 1u, 24, 14, 13, 0, 7, 101, 8u, (uint16_t)3, s_dc_rows, NULL, -1, 0u, 0u}, }; static content_page_id_t s_open = CONTENT_PAGE_COUNT; /* 当前打开的页(COUNT = 未打开) */ static uint16_t s_sel; /* 列表页选中行(全局下标,跨页连续) */ static lv_obj_t *s_scr; /* 页面屏幕(进入时建、返回时毁) */ static lv_obj_t *s_cont; /* 页面全屏容器(焦点/键入口 + 自绘载体) */ /* flat:透明、无边框/圆角/内边距——剥离 mono 主题给普通 lv_obj 附加的 card 样式(同 ui_menu_view.c) */ /* 用常量样式(LV_STYLE_CONST_INIT):属性存静态数组,零 LV_MEM 池分配; * 动态样式首次 set_prop 会从池分配 values_and_props 且永不释放(x64 实测 88B/个) */ static const lv_style_const_prop_t cp_flat_props[] = { { .prop = LV_STYLE_BG_OPA, .value = { .num = LV_OPA_TRANSP } }, { .prop = LV_STYLE_BORDER_WIDTH, .value = { .num = 0 } }, { .prop = LV_STYLE_RADIUS, .value = { .num = 0 } }, { .prop = LV_STYLE_PAD_LEFT, .value = { .num = 0 } }, { .prop = LV_STYLE_PAD_RIGHT, .value = { .num = 0 } }, { .prop = LV_STYLE_PAD_TOP, .value = { .num = 0 } }, { .prop = LV_STYLE_PAD_BOTTOM, .value = { .num = 0 } }, { .prop = LV_STYLE_PAD_ROW, .value = { .num = 0 } }, { .prop = LV_STYLE_PAD_COLUMN, .value = { .num = 0 } }, LV_STYLE_CONST_PROPS_END }; LV_STYLE_CONST_INIT(cp_style_flat, cp_flat_props); /* 函数定义区 */ /* * 函数功能:判断两区域是否相交(等价 lv_area_intersect 非空判断;避免依赖 LVGL 私有头) * 入口参数:a - 区域 1 const lv_area_t * * b - 区域 2 const lv_area_t * * 返回值:true - 相交;false - 不相交 * 限定条件:无 * 函数说明:四边取 min/max 成交集,任一边为空即不相交(与 ui_menu_view.c 同实现) */ static bool cp_areas_intersect(const lv_area_t *a, const lv_area_t *b) { int32_t x1 = (a->x1 > b->x1) ? a->x1 : b->x1; int32_t y1 = (a->y1 > b->y1) ? a->y1 : b->y1; int32_t x2 = (a->x2 < b->x2) ? a->x2 : b->x2; int32_t y2 = (a->y2 < b->y2) ? a->y2 : b->y2; return (x1 <= x2 && y1 <= y2); } /* * 函数功能:绘制居中单行文本(标题/副标题) * 入口参数:layer - 绘制层 lv_layer_t * * text - 文本(Flash) const char * * y - 放置 y int32_t * 返回值:无 * 限定条件:text 非空;重绘区已含该行区域(调用方 culling) * 函数说明:x = (屏宽 - 文本实际步进)/2 居中;白色、静态字符串不拷内存 */ static void cp_draw_centered_text(lv_layer_t *layer, const char *text, int32_t y) { lv_area_t area; lv_draw_label_dsc_t ld; area.x1 = 0; area.x2 = CP_SCREEN_W - 1; area.y1 = y; area.y2 = y + 15; /* 覆盖字体行高(13)+ 余量 */ lv_draw_label_dsc_init(&ld); ld.base.layer = layer; ld.text = text; ld.text_static = 1; /* Flash 字符串:不拷内存 */ ld.color = lv_color_white(); /* 本 LVGL 版 lv_text_get_width 的 length=0 表示"不测量",必须传 strlen */ ld.ofs_x = (CP_SCREEN_W - lv_text_get_width(text, (uint32_t)strlen(text), LV_FONT_DEFAULT, 0)) / 2; ld.ofs_y = 0; lv_draw_label(layer, &ld, &area); } /* * 函数功能:页面自绘(浅 V 装饰 + 标题/副标题 + 行数据 + 页脚页码) * 入口参数:e - 事件指针 lv_event_t * * 返回值:无 * 限定条件:事件为 LV_EVENT_DRAW_MAIN,页面已打开 * 函数说明: 1. 浅 V 装饰几何与菜单根框一致(3 段线逐段画) * 2. 列表页窗口 = 选中行所在页(首可见行 = (sel/每页数)*每页数), * 行槽位置固定、数据随页切换;信息页全量静态绘制 * 3. 选中行整行白带(x=2..156)+ 黑字,非选中白字 * 4. 各区块按 culling 区跳过不在重绘区的部分 */ static void cp_draw_cb(lv_event_t *e) { const cp_def_t *d = &s_defs[s_open]; lv_layer_t *layer = lv_event_get_layer(e); /* "浅 V"装饰:3 段线(与菜单根框同几何) */ if (cp_areas_intersect(&s_deco_band, &layer->_clip_area)) { for (uint8_t i = 0; i < 3; i++) { lv_draw_line_dsc_t ld; lv_draw_line_dsc_init(&ld); ld.base.layer = layer; ld.color = lv_color_white(); ld.width = s_deco_seg_w[i]; ld.p1 = s_deco_segs[i][0]; ld.p2 = s_deco_segs[i][1]; lv_draw_line(layer, &ld); } } /* 标题(居中) */ { lv_area_t t; t.x1 = 0; t.x2 = CP_SCREEN_W - 1; t.y1 = CP_TITLE_Y; t.y2 = CP_TITLE_Y + 15; if (cp_areas_intersect(&t, &layer->_clip_area)) { cp_draw_centered_text(layer, d->title, CP_TITLE_Y); } } /* 副标题(仅信息页) */ if (d->subtitle != NULL) { lv_area_t t; t.x1 = 0; t.x2 = CP_SCREEN_W - 1; t.y1 = d->subtitle_y; t.y2 = d->subtitle_y + 15; if (cp_areas_intersect(&t, &layer->_clip_area)) { cp_draw_centered_text(layer, d->subtitle, d->subtitle_y); } } /* 行数据:列表页窗口跟随选中行所在页,信息页全量 */ int32_t first = 0; int32_t cnt = (int32_t)d->total_rows; if (d->selectable && d->rows_per_page > 0u) { first = (int32_t)(s_sel / d->rows_per_page) * d->rows_per_page; /* 窗口顶行 */ cnt = (int32_t)d->total_rows - first; if (cnt > (int32_t)d->rows_per_page) { cnt = (int32_t)d->rows_per_page; /* 末页不足整页时截断 */ } } for (int32_t i = 0; i < cnt; i++) { int32_t row_idx = first + i; /* 数据下标(跨页连续) */ int32_t slot_y = d->top_y + i * d->pitch; /* 行槽顶(窗口内位置固定) */ bool is_sel = (d->selectable != 0u) && (row_idx == (int32_t)s_sel); lv_color_t color = is_sel ? lv_color_black() : lv_color_white(); lv_area_t strip; /* 行条带(整宽)不在重绘区:跳过(culling) */ strip.x1 = 0; strip.x2 = CP_SCREEN_W - 1; strip.y1 = slot_y; strip.y2 = slot_y + d->pitch - 1; if (!cp_areas_intersect(&strip, &layer->_clip_area)) { continue; } /* 选中行:整行白带(x=2..156,高 band_h) */ if (is_sel && d->band_h > 0) { lv_draw_rect_dsc_t fd; lv_area_t band; band.x1 = 2; band.x2 = CP_SCREEN_W - 3; /* x=156(与参考图实测一致) */ band.y1 = slot_y; band.y2 = slot_y + d->band_h - 1; lv_draw_rect_dsc_init(&fd); fd.base.layer = layer; fd.bg_color = lv_color_white(); fd.bg_opa = LV_OPA_COVER; lv_draw_rect(layer, &fd, &band); } /* 左列(标签/名)与右列(值):固定列 x,垂直偏移 text_dy */ { lv_draw_label_dsc_t ld; /* ofs_x/ofs_y 相对 coords 原点(strip.y1=slot_y),勿再叠加 slot_y */ lv_draw_label_dsc_init(&ld); ld.base.layer = layer; ld.text = d->rows[row_idx].name; ld.text_static = 1; /* Flash 字符串:不拷内存 */ ld.color = color; /* 选中黑 / 非选中白 */ ld.ofs_x = d->left_x; ld.ofs_y = d->text_dy; lv_draw_label(layer, &ld, &strip); lv_draw_label_dsc_init(&ld); ld.base.layer = layer; ld.text = d->rows[row_idx].value; ld.text_static = 1; ld.color = color; ld.ofs_x = d->right_x; ld.ofs_y = d->text_dy; lv_draw_label(layer, &ld, &strip); } } /* 页脚"第 X 页,共 Y 页":列表页随选中行现算,信息页按参考图固定值 */ { char buf[32]; uint8_t cur; uint8_t total; lv_area_t t; if (d->selectable) { cur = (uint8_t)(s_sel / d->rows_per_page + 1u); total = (uint8_t)((d->total_rows + d->rows_per_page - 1u) / d->rows_per_page); } else { cur = d->fixed_cur_page; total = d->fixed_total_pages; } t.x1 = 0; t.x2 = CP_SCREEN_W - 1; t.y1 = CP_PAGE_LINE_Y; t.y2 = CP_PAGE_LINE_Y + 15; if (cp_areas_intersect(&t, &layer->_clip_area)) { /* lv_snprintf 而非 snprintf:MSVC 对 printf 族做格式串扫描,中文字面量会误报 C4819 */ lv_snprintf(buf, sizeof(buf), "第 %u 页,共 %u 页", (unsigned)cur, (unsigned)total); lv_draw_label_dsc_t ld; /* ofs 相对 coords 原点(t.y1=CP_PAGE_LINE_Y),垂直偏移取 0 */ lv_draw_label_dsc_init(&ld); ld.base.layer = layer; ld.text = buf; /* 栈缓冲:本页码每次现算 */ ld.color = lv_color_white(); ld.ofs_x = 6; ld.ofs_y = 0; lv_draw_label(layer, &ld, &t); } } } /* * 函数功能:页面按键事件入口(↑↓移选 / ESC·←返回) * 入口参数:e - 事件指针 lv_event_t * * 返回值:无 * 限定条件:容器已加入默认输入组并获焦 * 函数说明:1. ↑/↓:列表页移选(首尾环绕,跨页连续),窗口随页切换后重绘;信息页不响应 * 2. ESC/←:关闭页面返回菜单(导航状态未压层,原样还原) */ static void cp_key_cb(lv_event_t *e) { const cp_def_t *d = &s_defs[s_open]; lv_key_t key = (lv_key_t)lv_event_get_key(e); switch (key) { case LV_KEY_UP: if (d->selectable && d->total_rows > 0u) { /* 上移、首尾环绕:先加 total 再减 1 保证无符号下不为负 */ s_sel = (uint16_t)((s_sel + d->total_rows - 1u) % d->total_rows); lv_obj_invalidate(s_cont); /* 重绘(选中带/窗口/页码可能变化) */ } break; case LV_KEY_DOWN: if (d->selectable && d->total_rows > 0u) { s_sel = (uint16_t)((s_sel + 1u) % d->total_rows); /* 下移、越尾绕回首行 */ lv_obj_invalidate(s_cont); } break; case LV_KEY_ESC: case LV_KEY_LEFT: ui_content_page_close(); /* 返回菜单屏(焦点/导航状态还原) */ break; default: break; /* 其余键值忽略 */ } } /* * 函数功能:打开指定内容页(建屏 + 切屏 + 焦点置页面容器) * 入口参数:id - 内容页 ID content_page_id_t * 返回值:true - 成功;false - 失败(ID 无效/已有页面打开/对象创建失败) * 限定条件:ui_menu_init() 已完成(默认输入组与菜单屏存在) * 函数说明:1. lv_obj_create(NULL) 新建独立屏幕 + 全屏容器(自绘载体,无子对象) * 2. 容器入默认输入组并获焦后再切屏——按键立即由本模块接管 * 3. 导航状态不压层:菜单 s_depth/s_sel 原样保留,ESC 返回即还原 */ bool ui_content_page_open(content_page_id_t id) { if (id >= CONTENT_PAGE_COUNT || s_open != CONTENT_PAGE_COUNT) { return false; /* ID 无效或已有页面打开(防御) */ } s_scr = lv_obj_create(NULL); /* parent=NULL → 新建屏幕 */ if (s_scr == NULL) { return false; /* LVGL 池耗尽 */ } s_cont = lv_obj_create(s_scr); if (s_cont == NULL) { lv_obj_delete(s_scr); /* 回滚:屏已建、容器失败 */ s_scr = NULL; return false; } lv_obj_add_flag(s_cont, LV_OBJ_FLAG_IGNORE_LAYOUT); /* 绝对定位,不参与父级布局 */ lv_obj_set_size(s_cont, CP_SCREEN_W, CP_SCREEN_H); /* 全屏覆盖 */ lv_obj_add_style(s_cont, &cp_style_flat, 0); /* 透明、无边框:剥离主题 card */ lv_obj_add_flag(s_cont, LV_OBJ_FLAG_CLICK_FOCUSABLE); /* 可获焦:键事件入口 */ lv_obj_add_event_cb(s_cont, cp_key_cb, LV_EVENT_KEY, NULL); lv_obj_add_event_cb(s_cont, cp_draw_cb, LV_EVENT_DRAW_MAIN, NULL); s_open = id; s_sel = 0u; /* 每次进入从首行开始(页面按需建毁,不跨次保留) */ /* 焦点/键入口:入默认输入组并获焦(先于切屏,保证按键立即由本模块接管) */ lv_group_t *g = lv_group_get_default(); if (g != NULL) { lv_group_add_obj(g, s_cont); lv_group_focus_obj(s_cont); } lv_screen_load(s_scr); /* 切到内容页(菜单屏对象保留、不销毁) */ DBG_INFO("page open: id=%u rows=%u", (unsigned)id, (unsigned)s_defs[id].total_rows); return true; } /* * 函数功能:关闭当前内容页(焦点回菜单容器 + 切回菜单屏 + 销毁页面) * 入口参数:无 * 返回值:无 * 限定条件:ui_content_page_is_open() 为 true * 函数说明:1. 先移焦到菜单容器再删屏——防输入组焦点悬空(删除自动移出组) * 2. 切回菜单屏后销毁页面屏幕,LVGL 池内存随即回落 */ void ui_content_page_close(void) { if (s_open == CONTENT_PAGE_COUNT || s_cont == NULL) { return; /* 未打开(防御) */ } lv_obj_t *menu_cont = ui_menu_view_container(); if (menu_cont != NULL) { lv_group_focus_obj(menu_cont); /* 焦点先回菜单容器 */ lv_screen_load(lv_obj_get_screen(menu_cont)); /* 切回菜单屏(对象未动,原样还原) */ } DBG_INFO("page close: id=%u", (unsigned)s_open); lv_obj_delete(s_cont); /* 先删容器 */ lv_obj_delete(s_scr); /* 再删屏 */ s_scr = NULL; s_cont = NULL; s_open = CONTENT_PAGE_COUNT; s_sel = 0u; } /* * 函数功能:查询是否有内容页打开 * 入口参数:无 * 返回值:true - 有页面打开;false - 无 * 限定条件:无 * 函数说明:供逻辑层/调试判断当前处于菜单屏还是内容页 */ bool ui_content_page_is_open(void) { return (s_open != CONTENT_PAGE_COUNT); }