重构(UI): 统一组件协议 + 去手写链路 + 大文件按职责拆分 + 代码审查 + 剥离 Tools

## 1. 新增 UiComponent 顶层组件协议(不是负优化)

Components/Shared/Inc/UiComponent.hpp:纯接口、无状态,8 个方法
SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor。

Toolbar / TabBar / SignalPanel / PlotView 本来就各自拥有完全同名同签名的成员,
因此改造 = 加 override + 补 Area()(PlotView 另加一个非 const 的 QueryCursor 转发,
因为它自己的查询会惰性建立行缓存),实现代码零改动、静态派发、体积无增长。

刻意不把 UiWidget 当作它的基类:UiWidget 是"树节点"(父给尺寸、父下探脏区、
命中沿树链派发),而 PlotView(4 层位图缓存)/ SignalPanel(行位图缓存)是
"地盘宿主"。强行上树会推翻"父脏则子全脏"的前提,历史上正是这类耦合导致过
"后台渲染覆盖平移结果""命中矩形错位"的事故。头文件里已写明这条理由。
Toolbar/TabBar 是两者的交集:对外实现 UiComponent,对内持有一棵 UiWidget 树。

## 2. 宿主侧:四条手写链 -> 组件表

Application 新增 ComponentEntry{ component, target, chrome } 与 m_components
(底->顶:plot -> panel -> toolbar -> tabBar),BuildComponentTable() 装配一次。

- ApplyDpi        6 行逐个 SetScale  ->  1 个循环
- Relayout        手算三列 x 坐标    ->  UiStackRects(内容区, HORIZONTAL, {panelW, splitW, AUTO})
- WM_MOUSELEAVE   逐个 OnMouseLeave   ->  循环累积 chromeChanged / plotChanged
- WM_SETCURSOR    if/else if 链      ->  从表尾向前找首个命中者,取其 QueryCursor

顺带兑现历史缺口:UiLayout::UiStackRects 自引入以来第一次有了生产调用点。

有意保留显式的三条链(WM_LBUTTONDOWN / WM_LBUTTONUP / WM_MOUSEMOVE):它们各自带
组件专属后置动作(提交重命名、构建派生信号、面板联动滚动、更新拖拽光标、
plotOnlyMove 决定局部失效还是整帧失效)。塞进表里只能靠 lambda 字段,
可读性反而下降 —— 属于用户明确禁止的"过度抽象",因此不做。

## 3. 大文件按职责拆分(纯整函数搬移,逐像素 0 差异)

SignalPanelInteraction.cpp 2329 -> 831 行,拆出:
  SignalPanelEdit.cpp   616  内联编辑(筛选框、组名/信号名、数值、颜色与透明度)
  SignalPanelScroll.cpp 205  动画与滚动(折起、联动滚动、滚动到目标信号)
  SignalPanelMenu.cpp   527  弹出菜单(系数/偏移、镜像、右键菜单与命令回执)

Shared/PopupMenu.cpp 1901 -> 706 行,拆出:
  PopupMenuLayout.cpp 753  尺寸测量、项矩形排版、滚动可见性
  PopupMenuPaint.cpp  397  背景/阴影/项绘制
  Inc/PopupMenuInternal.hpp 103   pmdetail 常量 + inline 辅助(跨 TU 安全)

拆分判据是"职责"而非行数;共享常量一律收敛到 *Internal.hpp。

## 4. 代码审查(修 5 处)

- SignalPanelInteraction.cpp 头注释仍写"4 片",实际 6 片 -> 改为完整清单
- PopupMenuLayout.cpp 机械搬移时把构造函数一起搬走 -> 重复定义,只保留在交互分片
- PopupMenu.cpp 的 #include "PopupMenuInternal.hpp" 落进 namespace jdi 内 -> 移到文件头
- PopupMenuPaint.cpp 缺 UiControls.hpp(UiDrawEditFieldContent 未声明)-> 补 include
- 全仓复查历史删除符号残留引用(m_paintPlot / ClampCursorsAfterPan /
  PaintFreeMarkerTooltip / CaptureDecorCache / FitSinglePointSignal / CsvTable /
  DataFile / .preflight / structcheck)-> 零命中

## 5. Tools/ 剥离

git rm -r --cached Tools + .gitignore 新增 Tools/(本地文件保留,可随时复跑)。
ui_refactor 分支为全本地分支,另做 filter-branch 清除历史中的该目录。

## 6. 验证

- make release / make debug:零 error 零 warning
- rendercheck:A-H 八个场景哈希与改造前逐一相同
  (A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
   E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113)
  FUNC 7/7 PASS
- uicheck:69 passed, 0 failed
- TargetCheck.exe 891,392 B(< 900 KB 阈值)

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md
新增第五阶段章节;Documents/agent_mem.md 同步变更日志与「第七轮」硬约束。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-28 14:35:32 +08:00
1 parent 675c2a5f54
commit 0ec8055e95
20 files changed
+3287 -2678

No files matched your search

+753
View File
@@ -0,0 +1,753 @@
// PopupMenu 实现分片 —— 几何与排版(BuildLayout / 子菜单 / 滚动区 / 内联输入框位置)。
//
// 本文件只包含 PopupMenu 的成员函数定义(从 PopupMenu.cpp 机械搬移,行为不变)。
//
// ⚠️ 这里是菜单**唯一**的几何来源:`Paint` 与所有命中测试都读它产出的矩形,
// 不得在别处再反推一遍(否则会出现"看得见点不到")。
// ⚠️ `NaturalEditFieldWidth` / `EditSuffixWidth` 同时参与排版与绘制,改公式必须两处一致。
#include "PopupMenu.hpp"
#include "PopupMenuInternal.hpp"
namespace jdi
{
void PopupMenu::Setup(const UiFonts* fonts, const Theme* theme)
{
m_fonts = fonts;
m_theme = theme;
}
void PopupMenu::SetScale(float scale)
{
m_scale = (scale > 0.1f) ? scale : 1.0f;
}
float PopupMenu::ItemHeight(const MenuItem& item) const
{
switch (item.kind)
{
case MenuItemKind::SEPARATOR:
return kSeparatorHeight * m_scale;
case MenuItemKind::HEADER:
case MenuItemKind::HEADER_ACTION:
return kHeaderHeight * m_scale;
case MenuItemKind::SWATCHES:
return kSwatchRowHeight * m_scale;
case MenuItemKind::EDIT:
// 编辑项比普通项略高,给输入框留出上下呼吸空间。
return (kRowHeight + 4.0f) * m_scale;
case MenuItemKind::NORMAL:
default:
return kRowHeight * m_scale;
}
}
void PopupMenu::BuildLayout(const gd::RectF& bounds, int x, int y, bool expandUp)
{
m_itemRects.assign(m_items.size(), gd::RectF());
m_swatchRects.assign(m_items.size(), std::vector<gd::RectF>());
if (m_fonts == nullptr)
{
m_rect = gd::RectF();
return;
}
float contentWidth = kMinWidth * m_scale;
float totalHeight = kPadV * 2.0f * m_scale;
std::vector<float> heights(m_items.size(), 0.0f);
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
heights[index] = ItemHeight(item);
totalHeight += heights[index];
if (item.kind == MenuItemKind::SWATCHES)
{
const int count = std::max(0, std::min(item.swatchCount, 16));
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
static_cast<float>(count) * kSwatchSize * m_scale +
static_cast<float>(std::max(0, count - 1)) * kSwatchGap * m_scale;
contentWidth = std::max(contentWidth, width);
}
else if (item.kind == MenuItemKind::HEADER || item.kind == MenuItemKind::HEADER_ACTION)
{
// 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。
const float iconExtra = (item.kind == MenuItemKind::HEADER_ACTION)
? (kHeaderIconSize + kHeaderIconGap) * m_scale
: 0.0f;
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
kMarkerGap * m_scale + iconExtra +
UiMeasureTextWidth(m_fonts->uiSmallBold, item.label);
contentWidth = std::max(contentWidth, width);
}
else if (item.kind == MenuItemKind::NORMAL)
{
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label);
contentWidth = std::max(contentWidth, width);
}
else if (item.kind == MenuItemKind::EDIT)
{
// 说明文字 + 输入框;输入框宽度按数值文本自适应(与 EditFieldRect 保持一致)。
const float fieldWidth = NaturalEditFieldWidth(item);
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale +
fieldWidth;
contentWidth = std::max(contentWidth, width);
}
}
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
const float width = contentWidth;
// ------------------------------------------------------------------
// 超长菜单:限制高度 + 条目区可滚动 + 顶部/尾部固定项(见 `MenuLayoutConstraint`)。
// * 顶部固定 = **前导的 `HEADER` / `HEADER_ACTION`**(典型:「所属网格」标题)——
// 用户 2026-10-16 要求:滚动时标题必须留在最上方不动;
// * 尾部固定 = **最后一个分隔线及其之后的条目**(典型:「新建网格」)钉在底部;
// * 中间是滚动区(鼠标滚轮 / 键盘 / 滚动条),右侧画一根滚动条。
// ------------------------------------------------------------------
int pinnedFrom = -1;
int scrollFirst = 0;
if (m_constraint.maxHeight > 0.0f || m_constraint.maxScrollRows > 0)
{
for (int index = static_cast<int>(m_items.size()) - 1; index > 0; --index)
{
if (m_items[static_cast<std::size_t>(index)].kind == MenuItemKind::SEPARATOR)
{
pinnedFrom = index;
break;
}
}
while (scrollFirst < pinnedFrom &&
(m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER ||
m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER_ACTION))
{
++scrollFirst; // 前导标题固定在顶部,不参与滚动
}
}
float headHeight = 0.0f;
float scrollRegionHeight = 0.0f;
float tailHeight = 0.0f;
if (pinnedFrom > 0)
{
for (int index = 0; index < static_cast<int>(m_items.size()); ++index)
{
if (index >= pinnedFrom)
{
tailHeight += heights[static_cast<std::size_t>(index)];
}
else if (index < scrollFirst)
{
headHeight += heights[static_cast<std::size_t>(index)];
}
else
{
scrollRegionHeight += heights[static_cast<std::size_t>(index)];
}
}
}
float height = totalHeight;
m_scrollEnabled = false;
m_scrollRegionHeight = 0.0f;
m_scrollFirst = 0;
m_pinnedIndex = -1;
m_scrollViewport = gd::RectF();
m_scrollBarRect = gd::RectF();
// ⚠️ **不要在这里复位 `m_scrollBarDragging`**:拖拽滚动条时每一帧都会 `RebuildLayout()`,
// 在这里清零会把拖拽态当场吃掉(拖一下断一下)。该状态只在 `Open` / `Close` 里复位。
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写 / 拖滚动条)
// 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要**
// 滚动时才复位(`Open` 里已经为每次打开重置过)。
if (pinnedFrom > 0)
{
// 滚动视口高度上限:`maxHeight`(客户区像素 → 换算成视口可用高)与
// `maxScrollRows`(标准行数)**取更严格的那个**;0 = 该约束不生效。
float viewportLimit = 0.0f;
if (m_constraint.maxHeight > 0.0f)
{
// 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”,
// 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。
const float menuLimit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale);
if (menuLimit >= 40.0f * m_scale)
{
viewportLimit = std::max(
0.0f, menuLimit - kPadV * 2.0f * m_scale - headHeight - tailHeight);
}
}
if (m_constraint.maxScrollRows > 0)
{
const float rows =
static_cast<float>(m_constraint.maxScrollRows) * kRowHeight * m_scale;
viewportLimit = (viewportLimit > 0.0f) ? std::min(viewportLimit, rows) : rows;
}
if (viewportLimit >= kRowHeight * m_scale && scrollRegionHeight > viewportLimit + 0.5f)
{
const float viewportHeight = std::min(scrollRegionHeight, viewportLimit);
height = kPadV * 2.0f * m_scale + headHeight + viewportHeight + tailHeight;
m_scrollEnabled = true;
m_scrollFirst = scrollFirst;
m_pinnedIndex = pinnedFrom;
m_scrollRegionHeight = scrollRegionHeight;
m_scrollOffset = std::max(
0.0f, std::min(m_scrollOffset, std::max(0.0f, scrollRegionHeight - viewportHeight)));
}
else
{
m_scrollOffset = 0.0f;
}
}
else
{
m_scrollOffset = 0.0f;
}
// 带说明文字的 EDIT 项共用**同一个输入框宽度**:取其中最大的自然宽度,
// 再按“给最宽的说明文字留位 / 不超过内容区 3/4”夹取。
// ⚠️ 若每项各自按自己的文本长度算,同一面板里就会出现一个宽一个窄
// (时间轴行的文本更长、还带单位后缀,于是它的输入框比 Y 轴行宽一截)。
float labeledFieldWidth = 0.0f;
float widestLabel = 0.0f;
for (const MenuItem& item : m_items)
{
if (item.kind != MenuItemKind::EDIT || item.label.empty())
{
continue;
}
labeledFieldWidth = std::max(labeledFieldWidth, NaturalEditFieldWidth(item));
widestLabel = std::max(widestLabel,
UiMeasureTextWidth(m_fonts->uiSmall, item.label) +
kEditFieldGap * m_scale);
}
if (labeledFieldWidth > 0.0f)
{
const float rowContent =
std::max(40.0f * m_scale, (width - 2.0f * (kPadH + kItemPad) * m_scale));
const float limit = std::max(40.0f * m_scale,
std::min(rowContent * 0.75f, rowContent - widestLabel));
labeledFieldWidth = std::min(labeledFieldWidth, limit);
}
m_editFieldWidth = labeledFieldWidth;
float left = static_cast<float>(x);
// `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。
float top = expandUp ? (static_cast<float>(y) - height) : static_cast<float>(y);
// 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。
if (left + width > bounds.X + bounds.Width)
{
left = static_cast<float>(x) - width;
}
if (!expandUp && top + height > bounds.Y + bounds.Height)
{
top = static_cast<float>(y) - height;
}
left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f));
top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f));
m_rect = gd::RectF(left, top, width, height);
// 条目排布:头部固定区从菜单顶往下排;滚动区条目整体上移 `m_scrollOffset`;
// 尾部固定区从菜单底部往上对齐。
const float bodyTop = top + kPadV * m_scale;
const float tailTop = top + height - kPadV * m_scale - tailHeight;
const float scrollBarReserve = m_scrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f;
m_scrollViewport =
m_scrollEnabled
? gd::RectF(left + kPadH * m_scale, bodyTop + headHeight,
std::max(0.0f, width - kPadH * 2.0f * m_scale),
std::max(0.0f, height - kPadV * 2.0f * m_scale - headHeight - tailHeight))
: gd::RectF();
float cursorY = bodyTop;
float headCursorY = bodyTop;
float scrollCursorY = bodyTop + headHeight - m_scrollOffset;
float tailCursorY = (m_scrollEnabled) ? tailTop : bodyTop;
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
const float itemHeight = heights[index];
float rectY = cursorY;
float rectWidth = std::max(0.0f, width - kPadH * 2.0f * m_scale);
if (m_scrollEnabled)
{
const int itemIndex = static_cast<int>(index);
if (itemIndex < m_scrollFirst)
{
rectY = headCursorY;
headCursorY += itemHeight;
}
else if (itemIndex < m_pinnedIndex)
{
rectY = scrollCursorY;
scrollCursorY += itemHeight;
// 滚动区条目让出右侧滚动条的位置(对勾/文本不会压在滑块上)。
rectWidth = std::max(0.0f, rectWidth - scrollBarReserve);
}
else
{
rectY = tailCursorY;
tailCursorY += itemHeight;
}
}
m_itemRects[index] =
gd::RectF(left + kPadH * m_scale, rectY, rectWidth, itemHeight);
if (item.kind == MenuItemKind::SWATCHES)
{
const int count = std::max(0, std::min(item.swatchCount, 16));
std::vector<gd::RectF>& rects = m_swatchRects[index];
rects.reserve(static_cast<std::size_t>(count));
const float swatchY = rectY + (itemHeight - kSwatchSize * m_scale) * 0.5f;
const float startX = left + (kPadH + kItemPad) * m_scale;
for (int swatch = 0; swatch < count; ++swatch)
{
const float swatchX =
startX + static_cast<float>(swatch) * (kSwatchSize + kSwatchGap) * m_scale;
rects.push_back(gd::RectF(swatchX, swatchY, kSwatchSize * m_scale, kSwatchSize * m_scale));
}
}
cursorY += itemHeight;
}
// 滚动条轨道:贴在可滚动条目区的右内侧(只在条目区旁边,不会盖住固定区)。
if (m_scrollEnabled)
{
const float thickness = kScrollBarWidth * m_scale;
m_scrollBarRect = gd::RectF(left + width - kPadH * m_scale - thickness,
m_scrollViewport.Y, thickness, m_scrollViewport.Height);
}
}
bool PopupMenu::ScrollRegion(int& first, int& last) const
{
if (!m_scrollEnabled || m_pinnedIndex < 0)
{
first = 0;
last = static_cast<int>(m_items.size());
return false;
}
first = m_scrollFirst;
last = m_pinnedIndex;
return true;
}
gd::RectF PopupMenu::ScrollBarThumbRect() const
{
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f || m_scrollRegionHeight <= 0.0f)
{
return gd::RectF();
}
const float viewport = m_scrollViewport.Height;
if (viewport <= 0.0f)
{
return gd::RectF();
}
const float ratio = std::min(1.0f, viewport / m_scrollRegionHeight);
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, m_scrollBarRect.Height * ratio);
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f;
const float thumbY =
m_scrollBarRect.Y +
std::max(0.0f, std::min(1.0f, progress)) * std::max(0.0f, m_scrollBarRect.Height - thumbHeight);
return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight);
}
float PopupMenu::ScrollOffsetForThumbTop(float thumbTop) const
{
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f)
{
return m_scrollOffset;
}
const float viewport = m_scrollViewport.Height;
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
if (maxScroll <= 0.0f)
{
return 0.0f;
}
const float track = m_scrollBarRect.Height;
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, track * std::min(1.0f, viewport / std::max(1.0f, m_scrollRegionHeight)));
const float travel = std::max(0.0f, track - thumbHeight);
if (travel <= 0.0f)
{
return 0.0f;
}
const float relative = thumbTop - m_scrollBarRect.Y;
const float progress = std::max(0.0f, std::min(1.0f, relative / travel));
return progress * maxScroll;
}
bool PopupMenu::EnsureItemVisible(int index)
{
if (!m_scrollEnabled || index < m_scrollFirst || index >= m_pinnedIndex ||
m_scrollViewport.Height <= 0.0f)
{
return false;
}
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
const float relative = rect.Y - m_scrollViewport.Y;
float delta = 0.0f;
if (relative < 0.0f)
{
delta = relative;
}
else if (relative + rect.Height > m_scrollViewport.Height)
{
delta = relative + rect.Height - m_scrollViewport.Height;
}
if (std::fabs(delta) < 0.5f)
{
return false;
}
m_scrollOffset += delta;
// 重新排布(内部会重新夹取 `m_scrollOffset`)。
RebuildLayout();
return true;
}
bool PopupMenu::OnMouseWheel(int delta)
{
if (!m_open || !m_scrollEnabled || delta == 0)
{
return false;
}
const float previous = m_scrollOffset;
// 向上滚(`delta > 0`)= 看更靠前的条目 ⇒ 偏移减小。
m_scrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
// 重新排布:条目矩形依赖滚动偏移,同时会夹取到合法范围。
RebuildLayout();
return std::fabs(m_scrollOffset - previous) > 0.01f;
}
float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const
{
const std::wstring& widest =
(item.editValue.size() > item.value.size()) ? item.editValue : item.value;
const float natural = std::max(kEditFieldWidth * m_scale,
UiMeasureTextWidth(m_fonts->uiSmall, widest) + 20.0f * m_scale);
const float withSuffix = natural + EditSuffixWidth(item);
// 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。
return item.label.empty() ? std::max(withSuffix, kRowFieldWidth * m_scale) : withSuffix;
}
void PopupMenu::CloseSubmenu()
{
m_subOpen = false;
m_subParentIndex = -1;
m_subRect = gd::RectF();
m_subItems.clear();
m_subItemRects.clear();
m_subHoverIndex = -1;
m_subPressedIndex = -1;
}
void PopupMenu::BuildSubLayout(int parentIndex)
{
CloseSubmenu();
if (parentIndex < 0 || parentIndex >= static_cast<int>(m_items.size()))
{
return;
}
const MenuItem& parent = m_items[static_cast<std::size_t>(parentIndex)];
if (parent.kind != MenuItemKind::SUBMENU || parent.submenu.empty())
{
return;
}
m_subItems = parent.submenu;
// 与主菜单一致:宽度取条目中的最大者并夹在上下限之间。
float contentWidth = kMinWidth * m_scale;
float totalHeight = kPadV * 2.0f * m_scale;
for (const MenuItem& item : m_subItems)
{
totalHeight += ItemHeight(item);
if (item.kind == MenuItemKind::SWATCHES)
{
const int count = std::max(0, std::min(item.swatchCount, 16));
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
static_cast<float>(count) * kSwatchSize * m_scale +
static_cast<float>(std::max(0, count - 1)) * kSwatchGap * m_scale;
contentWidth = std::max(contentWidth, width);
}
else if (item.kind == MenuItemKind::HEADER)
{
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
kMarkerGap * m_scale +
UiMeasureTextWidth(m_fonts->uiSmallBold, item.label);
contentWidth = std::max(contentWidth, width);
}
else if (item.kind == MenuItemKind::EDIT)
{
const float fieldWidth = NaturalEditFieldWidth(item);
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale +
fieldWidth;
contentWidth = std::max(contentWidth, width);
}
else if (item.kind == MenuItemKind::NORMAL || item.kind == MenuItemKind::SUBMENU)
{
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label);
contentWidth = std::max(contentWidth, width);
}
}
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
const float width = contentWidth;
const float height = totalHeight;
const gd::RectF& parentRect = m_itemRects[static_cast<std::size_t>(parentIndex)];
const gd::RectF& bounds = m_buildBounds;
// 默认贴主菜单右缘并向上与父项顶端对齐;右侧空间不足时翻到左侧。
float left = m_rect.X + m_rect.Width - kSubOverlap * m_scale;
if (left + width > bounds.X + bounds.Width - 2.0f)
{
left = m_rect.X - width + kSubOverlap * m_scale;
}
left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f));
float top = parentRect.Y - kPadV * m_scale;
top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f));
m_subRect = gd::RectF(left, top, width, height);
m_subItemRects.assign(m_subItems.size(), gd::RectF());
float cursorY = top + kPadV * m_scale;
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const float itemHeight = ItemHeight(m_subItems[index]);
m_subItemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY,
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight);
cursorY += itemHeight;
}
m_subParentIndex = parentIndex;
m_subOpen = true;
m_subHoverIndex = -1;
m_subPressedIndex = -1;
}
int PopupMenu::SubItemAtPoint(int x, int y) const
{
if (!m_subOpen)
{
return -1;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const MenuItem& item = m_subItems[index];
if (item.kind == MenuItemKind::SEPARATOR || item.kind == MenuItemKind::HEADER)
{
continue;
}
if (!item.enabled)
{
continue;
}
if (UiPointInRect(m_subItemRects[index], fx, fy))
{
return static_cast<int>(index);
}
}
return -1;
}
float PopupMenu::EditSuffixWidth(const MenuItem& item) const
{
if (item.kind != MenuItemKind::EDIT || item.editSuffix.empty())
{
return 0.0f;
}
return UiMeasureTextWidth(m_fonts->uiSmall, item.editSuffix) + 8.0f * m_scale;
}
gd::RectF PopupMenu::EditFieldRect(int index) const
{
if (index < 0 || index >= static_cast<int>(m_items.size()))
{
return gd::RectF();
}
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
if (item.kind != MenuItemKind::EDIT)
{
return gd::RectF();
}
// 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度,
// 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。
// ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限,
// 让输入框独占整行。
// ⚠️ 带说明文字的项统一使用排布阶段算好的 `m_editFieldWidth`:
// 同一面板里所有这类输入框必须一样宽、右边界对齐。
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale);
const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale;
float width = NaturalEditFieldWidth(item);
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
if (item.label.empty())
{
// 整行输入框(无说明文字):占满内容区,视觉上就是一个独立的一行输入框。
width = contentWidth;
}
else
{
if (m_editFieldWidth > 0.0f)
{
width = m_editFieldWidth;
}
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
width = std::min(width, contentWidth * 0.75f);
}
const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale;
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width,
rect.Y + (rect.Height - height) * 0.5f, width, height);
}
gd::RectF PopupMenu::HeaderIconRect(int index) const
{
if (index < 0 || index >= static_cast<int>(m_items.size()))
{
return gd::RectF();
}
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
if (item.kind != MenuItemKind::HEADER_ACTION)
{
return gd::RectF();
}
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
const float size = std::min(kHeaderIconSize * m_scale, std::max(6.0f, rect.Height - 4.0f * m_scale));
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - size,
rect.Y + (rect.Height - size) * 0.5f, size, size);
}
bool PopupMenu::IsOnEditField(int x, int y) const
{
if (m_editIndex < 0)
{ return false;
}
const gd::RectF rect = EditFieldRect(m_editIndex);
return UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y));
}
bool PopupMenu::BeginItemEdit(int index)
{
if (!m_open || index < 0 || index >= static_cast<int>(m_items.size()))
{
return false;
}
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
if (item.kind != MenuItemKind::EDIT || !item.enabled)
{
return false;
}
// 输入框初值取「编辑初值」(未设置时用显示值),整段选中,便于直接键入新数值。
m_edit.Begin(item.editValue.empty() ? item.value : item.editValue, true, item.filter, 0u);
m_editIndex = index;
m_hoverIndex = index;
return true;
}
bool PopupMenu::EndItemEdit(bool commit, MenuResult& result)
{
if (m_editIndex < 0)
{
return false;
}
const int index = m_editIndex;
const std::wstring text = m_edit.Text();
m_editIndex = -1;
m_edit.End();
if (!commit || index >= static_cast<int>(m_items.size()))
{
return true;
}
MenuItem& item = m_items[static_cast<std::size_t>(index)];
// 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值);
// 键入文本同时记为**编辑初值**(`value` 与 `editValue` 不一致的行也能正确回显上次输入)。
item.value = text;
item.editValue = text;
result.valid = true;
result.scope = m_scope;
result.id = item.id;
result.editCommit = true;
result.editText = text;
return true;
}
void PopupMenu::RebuildLayout()
{
if (!m_open)
{
return;
}
const int subParent = m_subParentIndex;
BuildLayout(m_buildBounds, m_buildX, m_buildY, m_buildExpandUp);
// 主菜单重新排布后子菜单位置会失效:若之前是展开的则按新几何重建。
if (subParent >= 0)
{
BuildSubLayout(subParent);
}
}
bool PopupMenu::UpdateItemValue(int id, const std::wstring& value, const std::wstring& editValue)
{
for (MenuItem& item : m_items)
{
if (item.id == id && item.kind == MenuItemKind::EDIT)
{
const bool valueSame = (item.value == value);
const bool editSame = editValue.empty() || (item.editValue == editValue);
if (valueSame && editSame)
{
return true;
}
item.value = value;
if (!editValue.empty())
{
item.editValue = editValue;
}
RebuildLayout();
return true;
}
}
return false;
}
bool PopupMenu::UpdateItemLabel(int id, const std::wstring& label, bool checked, bool enabled)
{
for (MenuItem& item : m_items)
{
if (item.id == id && item.kind == MenuItemKind::NORMAL)
{
if (item.label == label && item.checked == checked && item.enabled == enabled)
{
return true;
}
item.label = label;
item.checked = checked;
item.enabled = enabled;
RebuildLayout();
return true;
}
}
return false;
}
} // namespace jdi