Files
Just-DataInspector/Components/Shared/Src/PopupMenuLayout.cpp
T
DeepSeek V4.1 Flash 0ec8055e95 重构(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 同步变更日志与「第七轮」硬约束。
2026-10-28 14:35:32 +08:00

754 lines
26 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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