重构显示逻辑为 MVP 架构,进行显示模块的解耦

This commit is contained in:
2026-03-24 19:52:22 +08:00
parent a4bf0962b2
commit 0690d6a00e
42 changed files with 2207 additions and 1417 deletions

View File

@@ -0,0 +1,447 @@
#include <stddef.h>
#include "menu_layout.h"
#include "utf8.h"
/******************************************************************************
* 模块: menu_layout.c
* 职责: 根据菜单树结构计算各级菜单在 LCD 上的显示矩形区域
*
* 设计要点:
* 1) 采用“先算当前层,再递归/级联算下一层”的方式布点;
* 2) 每级菜单都维护:
* - ptFirst[level]: 当前层循环链表的起点;
* - ptIndex[level]: 当前层遍历游标;
* - byMenuNum[level]: 当前层项数量(或当前位置计数);
* 3) 对 Y 方向越界提供多级回退策略(向上翻转/贴顶重排),尽量保证菜单可见。
*****************************************************************************/
/* 读取菜单节点导航指针集合(本文件局部辅助,避免 core 泄漏行为函数)。 */
static MenuLinks MenuLayout_GetLinks(const MenuItem *item)
{
MenuLinks links;
links.higher = item->ptHigher;
links.lower = item->ptLower;
links.before = item->ptBefore;
links.behind = item->ptBehind;
return links;
}
/* 读取菜单节点矩形(本文件局部辅助)。 */
static MenuRect MenuLayout_GetRect(const MenuItem *item)
{
MenuRect rect;
rect.wSPosX = item->wSPosX;
rect.wSPosY = item->wSPosY;
rect.wEPosX = item->wEPosX;
rect.wEPosY = item->wEPosY;
return rect;
}
/* 给菜单项写入矩形坐标的轻量封装,避免到处重复构造 MenuRect。 */
static void MenuLayout_SetRect(MenuItem *item, uint16_t sx, uint16_t sy, uint16_t ex, uint16_t ey)
{
item->wSPosX = sx;
item->wSPosY = sy;
item->wEPosX = ex;
item->wEPosY = ey;
}
/* 将理论挂接行压缩映射到可视区,避免超出可显示行数。 */
static uint16_t MenuLayout_MapMenuPos(uint16_t menuPos, uint8_t itemNum, uint8_t maxNum)
{
if ((itemNum > maxNum) && (menuPos >= maxNum))
{
return (uint16_t)(maxNum - (itemNum - menuPos));
}
return menuPos;
}
/* 子菜单 X 方向统一规则:从父框右边展开。 */
static void MenuLayout_CalcSubMenuX(const MenuRect *parentRect,
uint8_t maxLen,
const MenuLayoutConfig *config,
uint16_t *startX,
uint16_t *endX)
{
*startX = parentRect->wEPosX;
*endX = (uint16_t)(*startX + maxLen * config->menuWidth + config->menuXAdd);
}
/* 尝试将菜单框整体翻转到上方显示;成功返回 1失败返回 0。 */
static uint8_t MenuLayout_TryFlipUp(uint16_t *startY,
uint16_t *endY,
uint8_t itemNum,
uint16_t flipMinY,
const MenuLayoutConfig *config)
{
uint16_t candidateEndY;
uint16_t temp;
candidateEndY = (uint16_t)(*startY - (itemNum - 1) * config->lineHeight - config->menuYAdd);
if ((candidateEndY > flipMinY) && (candidateEndY < config->menuYMax))
{
temp = *startY;
*startY = candidateEndY;
*endY = (uint16_t)(temp + config->lineHeight);
return 1;
}
return 0;
}
/* Sub2 兜底:回退到顶层起始 Y并在底部做截断。 */
static void MenuLayout_FallbackClampToTop(const tagPMenuItem *ptIndex,
uint8_t itemNum,
uint16_t *startY,
uint16_t *endY,
const MenuLayoutConfig *config)
{
*startY = ptIndex[0]->wSPosY;
*endY = (uint16_t)(*startY + itemNum * config->lineHeight + config->menuYAdd);
if (*endY > config->menuYMax)
{
*endY = config->menuYMax;
}
}
/* Sub1 兜底:从顶层起始 Y 逐行上探,直到菜单框不越界。 */
static void MenuLayout_FallbackProbeUp(const tagPMenuItem *ptIndex,
uint8_t itemNum,
uint16_t *startY,
uint16_t *endY,
const MenuLayoutConfig *config)
{
*startY = ptIndex[0]->wSPosY;
*endY = (uint16_t)(*startY + itemNum * config->lineHeight + config->menuYAdd);
for (uint16_t i = 1; i < (uint16_t)(ptIndex[0]->wSPosY / config->lineHeight + 1); i++)
{
if (*endY > config->menuYMax)
{
*startY = (uint16_t)(ptIndex[0]->wSPosY - config->lineHeight * i);
if (*startY < config->lineHeight)
{
*startY = 0;
}
*endY = (uint16_t)(*startY + itemNum * config->lineHeight + config->menuYAdd);
}
else
{
break;
}
}
}
/* 计算单个菜单项“显示宽度”:
* - 中文等多字节字符按 2 列计宽ASCII 按 1 列;
* - 若该项有下级菜单且名称末尾未带特殊标记 '\x10',额外预留 1 列用于层级指示。 */
static uint8_t MenuLayout_ItemDisplayLen(const MenuItem *item)
{
uint8_t displayLen;
displayLen = MenuLayout_Utf8LenCal((uint8_t *)item->byName);
if (item->ptLower != NULL)
{
uint8_t byteLen = 0;
while ((byteLen < 50) && (item->byName[byteLen] != '\0'))
{
byteLen++;
}
if ((byteLen == 0) || (item->byName[byteLen - 1] != '\x10'))
{
displayLen += 1;
}
}
return displayLen;
}
/* UTF-8 显示长度统计:
* utf8_next() 每次返回一个 Unicode 字符占用的字节数 n
* 约定 n>1 视为“宽字符”(占 2 列),否则占 1 列。 */
uint8_t MenuLayout_Utf8LenCal(uint8_t *str)
{
uint8_t strLen = 0;
uint32_t unicode;
uint8_t index = 0;
uint8_t n = 0;
while (str[index] != '\0')
{
n = utf8_next(str + index, &unicode);
strLen += (n > 1) ? 2 : 1;
index += n;
}
return strLen;
}
/******************************************************************************
* 函数: MenuLayout_CharLenCal
* 作用:
* 1) 统计 bylevel 下一层bylevel+1菜单项总数
* 2) 计算该层中最长显示宽度(用于决定菜单框宽度);
* 3) 为该层每个节点写 wPos从 1 开始的位置序号);
* 4) 把“下一层项数”写回父节点 wNum。
*
* 变量字典:
* - bylevel: 输入为父层级,函数内部会自增到“子层级”;
* - byMenuNum[level]: level 层累计项数(同时作为 wPos 的来源);
* - ptFirst[level]: level 层循环链表的起点节点;
* - ptIndex[level]: level 层当前遍历节点;
* - byMaxLen: 本层所有项显示宽度的最大值(单位: 字符列)。
*****************************************************************************/
uint8_t MenuLayout_CharLenCal(uint8_t bylevel, uint8_t *byMenuNum, tagPMenuItem *ptFirst, tagPMenuItem *ptIndex)
{
uint8_t displayLen;
uint8_t byMaxLen = 0;
MenuLinks links;
/* 进入下一层,从父节点的 lower 作为该层环形链表起点。 */
ptFirst[bylevel + 1] = ptIndex[bylevel]->ptLower;
bylevel = bylevel + 1;
ptIndex[bylevel] = ptFirst[bylevel];
byMenuNum[bylevel] = 1;
ptIndex[bylevel]->wPos = 1;
/* 固定上限 300 作为保护,避免链表异常导致死循环。 */
for (uint16_t wLoop = 0; wLoop < 300; wLoop++)
{
links = MenuLayout_GetLinks(ptIndex[bylevel]);
displayLen = MenuLayout_ItemDisplayLen(ptIndex[bylevel]);
if (byMaxLen < displayLen)
{
byMaxLen = displayLen;
}
ptIndex[bylevel] = links.behind;
if (ptIndex[bylevel] == ptFirst[bylevel])
{
break;
}
byMenuNum[bylevel]++;
ptIndex[bylevel]->wPos = byMenuNum[bylevel];
}
/* 父节点记录子项数量,供分页/显示逻辑复用。 */
ptIndex[bylevel - 1]->wNum = byMenuNum[bylevel];
return byMaxLen;
}
/******************************************************************************
* 函数: MenuLayout_Sub2PosCal
* 作用: 计算二级子菜单(及其同层兄弟)的显示矩形
* 策略:
* - 先按“父项对齐”得到理想 Y
* - 若底部越界,尝试向上翻转;
* - 仍不满足则使用贴近顶层菜单的兜底摆放。
*
* 变量字典:
* - byMaxNum: 当前配置下可完整显示的最大行数;
* - byMenuPos: 当前菜单相对父菜单的挂接行(理论位置);
* - byItemNum: 当前层或下一层项数(代码中按阶段复用);
* - wSPosY/wEPosY: 菜单框起止 Y 坐标(含间距边界);
* - parentRect: 父菜单矩形,用于对子菜单做相对定位。
*
* 关键公式:
* - X 起点 = parentRect.wEPosX总是从父框右侧展开
* - X 终点 = X 起点 + byMaxLen * menuWidth + menuXAdd
* - Y 起点 = parentRect.wSPosY + (byMenuPos - 1) * lineHeight
* - Y 终点 = Y 起点 + 子项数 * lineHeight + menuYAdd。
*****************************************************************************/
void MenuLayout_Sub2PosCal(uint8_t bylevel, tagPMenuItem *ptFirst, tagPMenuItem *ptIndex, const MenuLayoutConfig *config)
{
uint16_t wSPosY;
uint16_t wEPosY;
uint8_t byMaxLen;
uint8_t byMaxNum;
uint16_t byMenuPos;
uint8_t byItemNum;
uint8_t byMenuNum[4];
MenuLinks links;
MenuRect parentRect;
/* 当前可完整容纳的最大行数(预留边框/间距)。 */
byMaxNum = (config->menuYMax - config->menuYMin - 6) / config->lineHeight;
ptIndex[bylevel] = ptFirst[bylevel];
for (uint16_t wLoop = 0; wLoop < 300; wLoop++)
{
links = MenuLayout_GetLinks(ptIndex[bylevel]);
if (links.lower != NULL)
{
byMaxLen = MenuLayout_CharLenCal(bylevel, byMenuNum, ptFirst, ptIndex);
parentRect = MenuLayout_GetRect(ptIndex[bylevel - 1]);
MenuLayout_CalcSubMenuX(&parentRect,
byMaxLen,
config,
&ptIndex[bylevel]->wSPosX,
&ptIndex[bylevel]->wEPosX);
/* byMenuPos: 当前项在同层中的理论挂接位置(必要时压缩映射)。 */
byMenuPos = ptIndex[bylevel]->wPos;
byItemNum = byMenuNum[bylevel];
byMenuPos = MenuLayout_MapMenuPos(byMenuPos, byItemNum, byMaxNum);
wSPosY = parentRect.wSPosY + (byMenuPos - 1) * config->lineHeight;
byItemNum = byMenuNum[bylevel + 1];
wEPosY = wSPosY + byItemNum * config->lineHeight + config->menuYAdd;
if (wEPosY < config->menuYMax)
{
/* 理想位置不越界,直接应用。 */
MenuLayout_SetRect(ptIndex[bylevel], ptIndex[bylevel]->wSPosX, wSPosY, ptIndex[bylevel]->wEPosX, wEPosY);
}
else
{
/* 底部越界:先尝试“向上翻转”放置整个菜单框。 */
if (!MenuLayout_TryFlipUp(&wSPosY, &wEPosY, byItemNum, config->menuYMin, config))
{
/* 仍不满足:回退到顶层菜单起始 Y再做截断兜底。 */
MenuLayout_FallbackClampToTop(ptIndex, byItemNum, &wSPosY, &wEPosY, config);
}
MenuLayout_SetRect(ptIndex[bylevel], ptIndex[bylevel]->wSPosX, wSPosY, ptIndex[bylevel]->wEPosX, wEPosY);
}
}
ptIndex[bylevel] = links.behind;
if (ptIndex[bylevel] == ptFirst[bylevel])
{
break;
}
}
}
/******************************************************************************
* 函数: MenuLayout_Sub1PosCal
* 作用: 计算一级子菜单(通常是主菜单下拉)的位置,并联动计算二级子菜单
* 说明: 与 Sub2 逻辑相近,但在兜底时会逐步向上平移,直到菜单框可容纳。
*
* 变量字典:
* - byMaxNum: 屏幕当前可显示的最大菜单行数;
* - byMenuPos: 当前节点理论挂接行(可能被映射压缩);
* - byItemNum: 当前下拉框条目数;
* - wTemp: 用于“翻转布局”时交换起止 Y
* - byMenuNum[4]: 分层统计数组,索引与菜单层级一一对应。
*
* 与 Sub2 的核心差异:
* - Sub1 兜底策略增加“逐行上探”,尝试找到不越界的最靠下位置,
* 视觉上更适合主菜单下拉场景。
*****************************************************************************/
void MenuLayout_Sub1PosCal(uint8_t bylevel, tagPMenuItem *ptFirst, tagPMenuItem *ptIndex, const MenuLayoutConfig *config)
{
uint16_t wSPosY;
uint16_t wEPosY;
uint8_t byMenuNum[4];
uint8_t byMaxLen;
uint8_t byMaxNum;
uint16_t byMenuPos;
uint8_t byItemNum;
MenuLinks links;
MenuRect parentRect;
byMaxNum = config->menuYMax / config->lineHeight;
ptIndex[bylevel] = ptFirst[bylevel];
for (uint16_t wLoop = 0; wLoop < 300; wLoop++)
{
links = MenuLayout_GetLinks(ptIndex[bylevel]);
if (links.lower != NULL)
{
byMaxLen = MenuLayout_CharLenCal(bylevel, byMenuNum, ptFirst, ptIndex);
parentRect = MenuLayout_GetRect(ptIndex[bylevel - 1]);
MenuLayout_CalcSubMenuX(&parentRect,
byMaxLen,
config,
&ptIndex[bylevel]->wSPosX,
&ptIndex[bylevel]->wEPosX);
byMenuPos = ptIndex[bylevel]->wPos;
byItemNum = byMenuNum[bylevel];
byMenuPos = MenuLayout_MapMenuPos(byMenuPos, byItemNum, byMaxNum);
wSPosY = parentRect.wSPosY + (byMenuPos - 1) * config->lineHeight;
byItemNum = byMenuNum[bylevel + 1];
wEPosY = wSPosY + byItemNum * config->lineHeight + config->menuYAdd;
if (wEPosY < config->menuYMax)
{
MenuLayout_SetRect(ptIndex[bylevel], ptIndex[bylevel]->wSPosX, wSPosY, ptIndex[bylevel]->wEPosX, wEPosY);
}
else
{
/* 底部越界:优先尝试一次整体翻转到上方。 */
if (!MenuLayout_TryFlipUp(&wSPosY, &wEPosY, byItemNum, config->lineHeight, config))
{
/* 二次兜底:从顶层菜单起始 Y 往上逐行试探,直到不越界。 */
MenuLayout_FallbackProbeUp(ptIndex, byItemNum, &wSPosY, &wEPosY, config);
}
MenuLayout_SetRect(ptIndex[bylevel], ptIndex[bylevel]->wSPosX, wSPosY, ptIndex[bylevel]->wEPosX, wEPosY);
}
/* 一级菜单定位完成后,继续计算二级菜单。 */
MenuLayout_Sub2PosCal(2, ptFirst, ptIndex, config);
}
ptIndex[bylevel] = links.behind;
if (ptIndex[bylevel] == ptFirst[bylevel])
{
break;
}
}
}
/******************************************************************************
* 函数: MenuLayout_PositionCal
* 作用: 入口函数,计算从 0 级到 2 级菜单的显示坐标
* 流程:
* 1) 先给 0 级菜单按屏宽等分;
* 2) 每个 0 级项若有下级,则计算其下拉框;
* 3) 再级联计算 1/2 级子菜单。
*
* 变量字典:
* - by0LevelNum: 顶层菜单数量;
* - byInterval: 顶层菜单等分后的列宽;
* - byMenuNum[0]: 当前顶层节点在同层中的序号(从 1 开始);
* - byMenuNum[1]: 顶层节点的直接子项数量(由 CharLenCal 写入);
* - ptFirst/ptIndex: 各层遍历上下文,在本函数中作为全流程共享状态。
*****************************************************************************/
void MenuLayout_PositionCal(tagPMenuItem ptMenuHead, uint8_t by0LevelNum, const MenuLayoutConfig *config)
{
tagPMenuItem ptFirst[4];
tagPMenuItem ptIndex[4];
uint8_t byMenuNum[4];
uint8_t byMaxLen;
uint8_t byInterval;
MenuLinks links;
/* 输入保护:头指针/配置为空,或 0 级菜单数量为 0 时直接返回。 */
if ((ptMenuHead == NULL) || (config == NULL) || (by0LevelNum == 0))
{
return;
}
ptFirst[0] = ptMenuHead;
ptIndex[0] = ptFirst[0];
byMenuNum[0] = 1;
ptIndex[0]->wPos = 1;
/* 顶层菜单按等分宽度布局。 */
byInterval = config->lcdSizeX / by0LevelNum;
for (uint16_t wLoop = 0; wLoop < by0LevelNum; wLoop++)
{
links = MenuLayout_GetLinks(ptIndex[0]);
if (links.lower != NULL)
{
/* 先统计该分支子项宽高需求,再写入 0 级菜单框。 */
byMaxLen = MenuLayout_CharLenCal(0, byMenuNum, ptFirst, ptIndex);
MenuLayout_SetRect(
ptIndex[0],
(byMenuNum[0] - 1) * byInterval,
config->lcdSizeY - config->lineHeight - byMenuNum[1] * config->lineHeight - config->menuYAdd,
(byMenuNum[0] - 1) * byInterval + byMaxLen * config->menuWidth + config->menuXAdd,
config->lcdSizeY - config->lineHeight);
/* 0 级完成后继续展开 1/2 级。 */
MenuLayout_Sub1PosCal(1, ptFirst, ptIndex, config);
}
ptIndex[0] = links.behind;
if (ptIndex[0] == ptFirst[0])
{
break;
}
byMenuNum[0]++;
ptIndex[0]->wPos = byMenuNum[0];
}
}