Files
Just-DataInspector/Components/Shared/Src/PopupMenuLayout.cpp
T
DeepSeek V4.1 Flash 97b361dedd fix(ui): 采样统计显示一致化、GX 拖拽在布局边界下可用、信号「移动到…」与拖拽排序
一、采样间隔/频率显示不一致(用户报告 1ms vs 1000us、100us 0ns)
* FormatDurationCompound 改为「先量化到整数皮秒、再整数分解」,
  彻底消除同一数值因浮点噪声落到不同单位/留下零尾数的问题;
  秒级分支与亚秒分支共用 totalPs 方案,次级进位/单位升级集中在一处。
* FormatFrequency 的单位选择改为按「舍入后的显示值」判定(999.9999999Hz
  与 1000Hz 均为 1.000kHz),并做向下校正避免 0.001kHz。

二、选区截取面板
* 提示文本改为用户指定原文「启用双光标模式以进行选区截取」。
* EdgePanelWidth 计入该提示的实际文本宽度;绘制改用 UiTextClipped 不再省略。
* viewHeight 补回漏减的 bottomPad,修掉条目区与按钮之间的空白。

三、未展开的信号条目栏悬停不再变成手型光标。

四、开启「显示布局边界」后 GX 标签可正常拖拽排序
* 抽出 PaintLaneReorderOverlay(吸附槽位 + 被拖轮廓),贴图与整幅重绘共用。
* 整幅重绘在 LANE_REORDER 时按 LaneDragOffset 逐格平移重画(被拖网格最后画),
  修掉调试模式下不跟随鼠标、无吸附提示、只能盲拖的问题。

五、信号右键新增「移动到…」二级菜单(位于「仅显示该信号」之上)
* 条目为「分组 N(组名)」,当前组打勾;无标题行;底部「新建分组」。
* MenuItem::submenuMaxRows + PopupMenu 独立子菜单滚动(滚动条、滚轮、视口命中过滤),
  IsScrollable 计入子菜单可滚状态。
* 数据层 DataSet::MoveSignalToGroup/MoveSignal 让时间轴随信号搬运
  (独占则移动、共享则复制);TabConfig::RemapAfterSignalMove 改写引用而非先删后插。
* Application::MoveSignalCore 统一入口,保证派生计算暂停、引用修正、选中迁移与同步一致。

六、信号列表支持按住拖拽排序(组内重排 / 跨组移动)
* 4px 阈值进入拖拽;条目脱离原位跟随鼠标(阴影 + 强调色描边 + 插入指示线)。
* 其它信号行/分组行按插入位置做避让位移动画(RowRect 统一叠加插值位移,
  宿主 kScrollTimerId 驱动 AdvanceSignalDragAnimation)。
* 数据层 DataSet::MoveSignal 支持任意位置;TabConfig::RemapAfterSignalReorder 线性重映射。
* 拖拽中禁用组名悬浮;双击改名与 InvalidateRows 均正确终止拖拽态。

验证:删 .o 全量重编译零 error / 零 warning;产物 873,984 B(< 1MB 预警阈值)。
本轮未使用鼠标/键盘/屏幕,未运行任何 GUI 探针,仅编译验证 + 源码级核对。
2026-11-03 19:27:43 +08:00

861 lines
30 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 || delta == 0)
{
return false;
}
// 子菜单展开且可滚动时优先滚子菜单:鼠标此时一定悬停在子菜单条目上
//(父项悬停即展开,滚轮自然是"翻这个列表"的意图)。
if (m_subOpen && m_subScrollEnabled)
{
const float previous = m_subScrollOffset;
m_subScrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
// 重新排布(内部会夹取 `m_subScrollOffset` 并刷新条目矩形)。
BuildSubLayout(m_subParentIndex);
return std::fabs(m_subScrollOffset - previous) > 0.01f;
}
if (!m_scrollEnabled)
{
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;
m_subScrollEnabled = false;
m_subScrollOffset = 0.0f;
m_subScrollRegionHeight = 0.0f;
m_subViewport = gd::RectF();
m_subScrollBarRect = gd::RectF();
m_subMaxRowsHeight = 0.0f;
}
void PopupMenu::BuildSubLayout(int parentIndex)
{
// `CloseSubmenu()` 会清零全部子菜单状态,但滚动(`OnMouseWheel`)需要重新排布时
// **保留滚动位置**:只有切换到另一个父项才从顶部开始。
const float savedScrollOffset = m_subScrollOffset;
const int savedParentIndex = m_subParentIndex;
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;
// 同一父项重新排布(滚动)时沿用原来的滚动位置。
m_subScrollOffset = (savedParentIndex == parentIndex) ? savedScrollOffset : 0.0f;
m_subScrollEnabled = false;
m_subScrollRegionHeight = 0.0f;
m_subViewport = gd::RectF();
m_subScrollBarRect = gd::RectF();
m_subMaxRowsHeight = 0.0f;
// 与主菜单一致:宽度取条目中的最大者并夹在上下限之间。
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);
// ------------------------------------------------------------------
// 子菜单条目过多时可滚动(`MenuItem::submenuMaxRows`)。
// 与主菜单的"头部固定 / 尾部固定"不同,这里**整段一起滚**:
// 子菜单的条目形态单一(分组列表 + 末段分隔线 + 新建分组),整体滚动即可,
// 且不会出现"两个固定区各占一半高度、中间只剩一两行"的糟糕观感。
// ------------------------------------------------------------------
const float subContentHeight = totalHeight - kPadV * 2.0f * m_scale;
if (parent.submenuMaxRows > 0)
{
const float rowsLimit =
static_cast<float>(parent.submenuMaxRows) * kRowHeight * m_scale;
m_subMaxRowsHeight = rowsLimit;
if (rowsLimit >= kRowHeight * m_scale && subContentHeight > rowsLimit + 0.5f)
{
m_subScrollEnabled = true;
m_subScrollRegionHeight = subContentHeight;
}
}
const float width = contentWidth;
const float height =
m_subScrollEnabled ? (m_subMaxRowsHeight + kPadV * 2.0f * m_scale) : 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());
// 条目可视区:整段滚动模式下就是内容区;让出右侧滚动条的位置。
const float subScrollReserve =
m_subScrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f;
m_subViewport = gd::RectF(left + kPadH * m_scale, top + kPadV * m_scale,
std::max(0.0f, width - kPadH * 2.0f * m_scale - subScrollReserve),
m_subScrollEnabled ? m_subMaxRowsHeight : std::max(0.0f, height - kPadV * 2.0f * m_scale));
if (m_subScrollEnabled)
{
// 偏移夹到合法范围(内容高 - 可视高)。
m_subScrollOffset = std::max(
0.0f, std::min(m_subScrollOffset, std::max(0.0f, subContentHeight - m_subMaxRowsHeight)));
m_subScrollBarRect =
gd::RectF(left + width - kPadH * m_scale - kScrollBarWidth * m_scale,
top + kPadV * m_scale, kScrollBarWidth * m_scale, m_subMaxRowsHeight);
}
float cursorY = top + kPadV * m_scale - m_subScrollOffset;
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, m_subViewport.Width), 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);
// 滚动中的子菜单:可视区之外的条目矩形仍然存在(只是被裁掉),必须先按视口过滤,
// 否则滚出视野的条目还能被点中(表现为"点到看不见的东西")。
if (m_subScrollEnabled && !UiPointInRect(m_subViewport, fx, fy))
{
return -1;
}
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;
}
bool PopupMenu::UpdateSubmenuItemLabel(int id, const std::wstring& label, bool checked,
bool enabled)
{
for (std::size_t parentIndex = 0; parentIndex < m_items.size(); ++parentIndex)
{
MenuItem& parent = m_items[parentIndex];
if (parent.kind != MenuItemKind::SUBMENU)
{
continue;
}
for (MenuItem& option : parent.submenu)
{
if (option.id != id || option.kind != MenuItemKind::NORMAL)
{
continue;
}
if (option.label == label && option.checked == checked && option.enabled == enabled)
{
return true;
}
option.label = label;
option.checked = checked;
option.enabled = enabled;
// 子菜单是 `parent.submenu` 的**快照**(`m_subItems`),必须重建才生效。
if (m_subOpen && m_subParentIndex == static_cast<int>(parentIndex))
{
BuildSubLayout(static_cast<int>(parentIndex));
}
return true;
}
}
return false;
}
} // namespace jdi