/* * 模块名称:UI Content Page (内容页框架) * 模块功能:菜单叶子节点进入的内容页(独立全屏页面)框架——全屏容器 * 自绘全部内容(浅 V 装饰 + 居中标题 + 行数据 + 页脚页码),无子对象; * 只持机制:屏/容器按需建毁、焦点、按键分发、描述符驱动通用绘制; * 不持任何页面数据——各页数据/几何/钩子由 ui_page_xxx 模块经 * content_page_def_t 接入描述符表 s_defs[](架构见 内容页架构.md); * 进入时切屏并获焦、ESC 返回菜单屏并还原焦点; * 不持有导航状态——由逻辑层 ui_menu.c 在 ENTER 叶子节点时驱动 * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) * 作者:王建锋 * 创建日期:2026-09-09 * 修改记录: * 2026-09-09 王建锋 创建:装置信息/交流量/直流量三页(参考图 10/20/21 实测几何) * 2026-09-09 王建锋 "浅 V"装饰几何提取至共享 ui_deco 模块(不再本地重复定义) * 2026-09-09 王建锋 拆分为"框架 + 页面插件"模型(内容页架构.md):三页数据移入 * ui_page_xxx 模块,本文件只留机制 + 描述符表;绘制/按键改 * 按描述符驱动(静态表/refresh + 4 钩子),行为零变化 */ /* 头文件包含区 */ #include #include "ui_content_page.h" #include "ui_page_device_info.h" #include "ui_page_ac.h" #include "ui_page_dc.h" #include "ui_menu_view.h" #include "ui_deco.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; * 字形顶距(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_ROW_BUF_LEN 32 /* 动态行栈缓冲长度(同帧同步光栅化;超出行带即被裁切) */ /* 静态变量定义区 */ /* * 描述符表:页 ID → 页面模块描述符(每页一行;新增页 = 枚举值 + 此处一行, * 框架机制不变,见 内容页架构.md §七) */ static const content_page_def_t *const s_defs[CONTENT_PAGE_COUNT] = { [CONTENT_PAGE_DEVICE_INFO] = &ui_page_device_info_desc, [CONTENT_PAGE_AC] = &ui_page_ac_desc, [CONTENT_PAGE_DC] = &ui_page_dc_desc, }; 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); } /* * 函数功能:取页面总行数(静态页 = 描述符 row_count,动态页 = count_cb) * 入口参数:d - 页面描述符 const content_page_def_t * * 返回值:总行数 uint16_t * 限定条件:d 非空 * 函数说明:行数唯一入口;绘制(窗口/页脚)与按键(移选取模)均经此取 */ static uint16_t cp_row_total(const content_page_def_t *d) { if (d->count_cb != NULL) { return d->count_cb(); } return d->row_count; } /* * 函数功能:绘制居中单行文本(标题/副标题) * 入口参数: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 装饰:共享 ui_deco 模块(与菜单根框同几何) * 2. 行数:静态页取 row_count,动态页取 count_cb(); * 列表页窗口 = 选中行所在页(首可见行 = (sel/每页数)*每页数), * 行槽位置固定、数据随页切换;信息页全量静态绘制 * 3. 行文本:静态页直绘 Flash 表(不拷内存);动态页经 refresh() * 逐行取入栈缓冲(同帧同步光栅化,安全) * 4. 选中行整行白带(x=2..156)+ 黑字,非选中白字 * 5. 各区块按 culling 区跳过不在重绘区的部分;末尾调 draw_cb(页自定义内容) */ static void cp_draw_cb(lv_event_t *e) { const content_page_def_t *d = s_defs[s_open]; lv_layer_t *layer = lv_event_get_layer(e); /* "浅 V"装饰:共享 ui_deco 模块(与菜单根框同几何) */ ui_deco_draw(layer, &layer->_clip_area); /* 标题(居中) */ { 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 nrows = (int32_t)cp_row_total(d); int32_t first = 0; int32_t cnt = nrows; if (d->selectable != 0u && d->rows_per_page > 0u) { first = (int32_t)(s_sel / d->rows_per_page) * d->rows_per_page; /* 窗口顶行 */ cnt = nrows - first; if (cnt > (int32_t)d->rows_per_page) { cnt = (int32_t)d->rows_per_page; /* 末页不足整页时截断 */ } } bool dynamic = (d->refresh != NULL); char dyn_name[CP_ROW_BUF_LEN]; /* 动态行栈缓冲(同帧同步光栅化) */ char dyn_value[CP_ROW_BUF_LEN]; 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); } /* 行文本:静态 = Flash 表(不拷内存);动态 = refresh 逐行取(栈缓冲) */ const char *name; const char *value; if (dynamic) { d->refresh((uint16_t)row_idx, dyn_name, dyn_value); name = dyn_name; value = dyn_value; } else { name = d->rows[row_idx].name; value = d->rows[row_idx].value; } { 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 = name; ld.text_static = (uint32_t)(!dynamic); /* 静态不拷;动态缓冲同帧内有效 */ 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 = value; ld.text_static = (uint32_t)(!dynamic); 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 pages; lv_area_t t; if (d->selectable != 0u) { cur = (uint8_t)(s_sel / d->rows_per_page + 1u); pages = (uint8_t)((nrows + (int32_t)d->rows_per_page - 1) / d->rows_per_page); } else { cur = d->fixed_cur_page; pages = 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)pages); 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); } } /* 页面钩子:自定义内容(表单/操作页;在内置区块之后绘制) */ if (d->draw_cb != NULL) { d->draw_cb(layer, &layer->_clip_area); } } /* * 函数功能:页面按键事件入口(↑↓移选 / ESC·←返回 / 页面钩子) * 入口参数:e - 事件指针 lv_event_t * * 返回值:无 * 限定条件:容器已加入默认输入组并获焦 * 函数说明: 1. ESC/←:恒关页返回菜单(框架保证,页面钩子不可拦截) * 2. 其余键:key_cb 钩子先调(返回 true = 页已处理,框架跳过内置) * 3. 内置:↑/↓ 列表页移选(首尾环绕,跨页连续),窗口随页切换后重绘; * 信息页不响应;动态行数可能缩小,选中行越界时重置回首行 */ static void cp_key_cb(lv_event_t *e) { const content_page_def_t *d = s_defs[s_open]; lv_key_t key = (lv_key_t)lv_event_get_key(e); /* ESC/←:关闭页面返回菜单屏(焦点/导航状态还原) */ if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { ui_content_page_close(); return; } /* 页面钩子先调:true = 页已处理,框架跳过内置语义 */ if (d->key_cb != NULL && d->key_cb(key)) { return; } switch (key) { case LV_KEY_UP: { uint16_t total = cp_row_total(d); if (d->selectable != 0u && total > 0u) { if (s_sel >= total) { s_sel = 0u; /* 动态行数可能缩小:选中行越界重置回首行 */ } /* 上移、首尾环绕:先加 total 再减 1 保证无符号下不为负 */ s_sel = (uint16_t)((s_sel + total - 1u) % total); lv_obj_invalidate(s_cont); /* 重绘(选中带/窗口/页码可能变化) */ } break; } case LV_KEY_DOWN: { uint16_t total = cp_row_total(d); if (d->selectable != 0u && total > 0u) { if (s_sel >= total) { s_sel = 0u; /* 动态行数可能缩小:选中行越界重置回首行 */ } s_sel = (uint16_t)((s_sel + 1u) % total); /* 下移、越尾绕回首行 */ lv_obj_invalidate(s_cont); } 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 无效或已有页面打开(防御) */ } const content_page_def_t *d = s_defs[id]; if (d == NULL) { DBG_ERROR("page open: id=%u no descriptor (enum/table drift)", (unsigned)id); return false; /* 枚举/描述符表漂移:拒开并留痕,不崩溃 */ } 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)cp_row_total(d)); 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); }