Files
Just-DataInspector/Utilities/Src/PopupMenu.cpp
T
DeepSeek V4.1 Flash a2a38236fe 边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开
一、两个「边沿计数」按「画出来的曲线」判定(原先按原始采样值 → 应用过「调整」的信号恒为 0)
- 用户复述的权威定义:设定一个横向(Y 轴)光标,信号绘制出来的曲线从下往上穿过该幅值 = 上升沿,
  从上往下穿过 = 下降沿。
- 症状:VERY_BIG_DATA 的「编码器B相」设系数 50 + 时间轴偏移 -20s,把光标拖到画面中间,
  很规整的方波却统计出 0/0。
- 根因:EdgeCountsFor / EdgeCenterCountsFor 只比较 signal.values 的原始采样值,而横向光标读的是
  屏幕上的值(已乘系数、已加偏移)→ 原始值域与阈值完全不相交。
- 修法:判定值一律取 ApplyDrawTransform(style, values[SourceIndexFor(style, count, index)]);
  * 中心轴两侧必须套用同一套变换(drawn - drawnCenter);
  * 水平镜像时曲线数组按源下标存储、而统计区间是显示下标 → 请求曲线窗口前必须把
    [needFirst, needEnd) 映射为 [count-needEnd, count-needFirst);
  * 缓存必须带样式指纹:EdgeCountEntry / EdgeCenterCountEntry 新增 styleKey
    (EdgeStyleKey():gain/offsetY 按 1e6 定点 + absolute/mirrorVertical/mirrorHorizontal 位),
    否则改完「调整」仍会命中变换前的旧计数。

二、时间区间之外的采样不得计入(区间边界点)
- 症状:A 在阈值以下、B 超过阈值;双光标下一个光标正好在 B 点,旧实现把 B 算成一个上升沿。
- 根因:EdgeIndexRange 在左端多取了一个采样点(if (begin > 0) --begin;)。
- 修法:删掉该外扩,收紧为「只有两个端点都落在 [begin, end) 内的采样对才计入」
  (begin = lower_bound(rangeMin)、end = upper_bound(rangeMax),调用方从 begin+1 枚举到 end-1)。
- 结果:把区间起点从 times[crossing-1] 移到 times[crossing] 恰好少一个上升沿。

三、派生信号把 offsetTime 也烘进采样值(推翻 §9.19 的「唯一例外」)
- 用户要求:新信号默认不启用任何「调整」时就必须与网格里的预览逐点重合。
- 修法(Application::BuildDerivedSignal,在「值变换 + 水平镜像反序」之后追加一步):
  新信号第 i 个采样 = 已变换的曲线在 time[i] - offsetTime 处的取值(取最近的采样点);
  单调时间轴用只前进的游标(整段 O(n));平移后越界的采样置 NaN(与预览一致);
  新信号样式全部保持默认(gain=1 / absolute=false / 两个镜像=false / offsetY=0 / offsetTime=0)。
- 退化路径:时间轴非单调或时间列缺失时无法按下标推进取样,退回把 offsetTime 带在样式上
  (两条路径的显示效果相同)。

四、图例托底统一为 GX 编号徽标同款 + 新增更小字体 uiTiny
- 托底高度 = kLaneBadgeHeight、上下边界与 LaneBadgeRect() 完全一致、圆角 = 高度的一半(胶囊形)、
  配色与描边与徽标相同(选中态 accentSoft/accent)、水平内边距 = 胶囊半径。
- 文本改用新增字体 UiFonts::uiTiny(10 逻辑 px):原 uiSmall(12px) 几乎顶满 17px 高的胶囊。
- LegendItem 拆出 plateWidth(托底宽 = 内容宽 + 2×内边距),条目步进 = plateWidth + kLegendItemGap;
  kLegendPlateRadius 已删除(半径一律由徽标高度推出)。

五、「系数/偏移」「镜像」二级面板改为向上展开
- PopupMenu::Open(..., bool expandUp = false):true 时 (x, y) 是菜单的左下角、菜单向上展开;
  BuildLayout 增加同参数并记录 m_buildExpandUp(RebuildLayout 必须复用,否则数值回写触发的重排
  会让面板跳回向下展开)。
- SignalPanel::OpenGainMenu / OpenMirrorMenu:锚点 = (button.X, button.Y - kAdjustMenuGap),
  即「面板左下角对准按钮左上角、再略微上去一点」(kAdjustMenuGap = 4 逻辑像素)。

六、验证(全部离屏,不占用用户的鼠标 / 键盘 / 屏幕)
- .preflight/uitest 新增 [AR]:
  * 边沿计数 plain / gain=50 / offsetY / mirrorVertical / mirrorHorizontal 逐个与「独立参考实现」
    逐位一致;offsetY 用例给出 A/B:按原始采样值算会得到 7/6 个边沿(正是用户报告的缺陷);
  * 区间边界:起点从 crossing-1 移到 crossing 恰好少一个上升沿;
  * 中心轴:absolute + offsetY 的样式下与参考实现逐位一致(32234 个边沿量级);
  * 派生信号:无偏移时全部 128 万采样逐点等于 transform(滤波);带 +10 个平均采样周期的偏移时,
    抽样点等于 transform(滤波) 在 time[i] - offsetTime(最近采样)处的取值;样式全默认;
  * 图例托底:height == 徽标高度、Y == 徽标 Y、radius == height/2、色块与文本都在托底之内;
  * 调整面板:菜单左边界 == 按钮左边界、菜单底 == 按钮顶 - 4*scale、且不覆盖按钮(两个按钮各测一次);
  * GX 拖拽让位:±0.6H 各让位 1 格(目标 0 / 3),±0.4H 与恰好 ±0.5H 都不让位(严格「超过」)。
- 同步修正 harness 里两处「旧规则」断言(否则必然 FAIL 并误导后来者):
  [AP] 「派生信号继承调整变换」→ 「样式默认 + 采样值按 time[i]-offsetTime 烘焙」;
  [AN] 「图例带无托底(网格线可透出)」→ 「装饰矩形逐块、无一块跨越整条带、未被托底覆盖的列
  仍能看到底层内容」。
- 合计 722/722 通过;make release 零警告(本机 GCC 15.2.0 UCRT;Target.exe 被占用时链接会报
  Permission denied,已用临时 -o 名字验证链接正确性)。

规则同步:Documents/agent_mem.md 新增 §9.21,§9.19 的「offsetTime 仍需继承」标注为已作废,
新增 §11.2 第 21–25 条硬约束,并补变更日志一行。
2026-10-14 11:40:49 +08:00

1505 lines
48 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.
#include "PopupMenu.hpp"
#include <algorithm>
#include <cmath>
namespace jdi
{
namespace
{
const float kRowHeight = 27.0f; /**< 普通项高度。 */
const float kHeaderHeight = 22.0f; /**< 分组标题高度。 */
const float kSeparatorHeight = 9.0f; /**< 分隔线高度。 */
const float kSwatchRowHeight = 32.0f; /**< 色块行高度。 */
const float kPadV = 5.0f; /**< 菜单上下内边距。 */
const float kPadH = 5.0f; /**< 菜单左右内边距。 */
const float kItemPad = 8.0f; /**< 条目内文本的左右内缩(所有条目统一)。 */
const float kMarkerColumn = 18.0f; /**< 条目**右侧**的选中标记列宽度(对勾画在这里)。 */
const float kMarkerGap = 4.0f; /**< 文本与右侧标记列之间的间隔。 */
const float kMinWidth = 148.0f; /**< 菜单最小宽度。 */
const float kMaxWidth = 340.0f; /**< 菜单最大宽度。 */
const float kCorner = 6.0f; /**< 菜单圆角半径。 */
const float kSwatchSize = 22.0f; /**< 色块尺寸。 */
const float kSwatchGap = 5.0f; /**< 色块间距。 */
const float kEditFieldWidth = 88.0f; /**< EDIT 项输入框宽度。 */
const float kEditFieldGap = 6.0f; /**< 说明文字与输入框之间的最小间隔。 */
const float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */
const float kSubArrowSize = 4.5f; /**< 子菜单指示箭头尺寸。 */
const float kSubOverlap = 2.0f; /**< 子菜单与主菜单重叠的宽度(避免中间露出缝隙)。 */
const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */
const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */
const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */
/**
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
*
* 用一段圆弧 + 一个三角箭头拼成,与界面里其它自绘图标(加号、叉号)保持同一笔画风格。
* 不依赖任何字体或资源,因此在任何 DPI 下都清晰。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] rect 图标外接矩形(正方形)。
* @param [IN] color 笔画颜色。
* @param [IN] scale DPI 缩放比例。
*/
void PaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float scale)
{
const float inset = 2.5f * scale;
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f,
rect.Height - inset * 2.0f);
if (box.Width <= 1.0f || box.Height <= 1.0f)
{
return;
}
gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu,
color & 0xFFu),
std::max(1.0f, 1.5f * scale));
pen.SetStartCap(gd::LineCapRound);
pen.SetEndCap(gd::LineCapRound);
// 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。
g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f);
// 箭头:位于缺口末端(约 300° 方向),指向前进方向。
const float cx = box.X + box.Width * 0.5f;
const float cy = box.Y + box.Height * 0.5f;
const float radius = box.Width * 0.5f;
const float angle = 300.0f * 3.14159265f / 180.0f;
const float tipX = cx + std::cos(angle) * radius;
const float tipY = cy + std::sin(angle) * radius;
const float size = std::max(2.5f, 3.2f * scale);
gd::PointF arrow[3];
arrow[0] = gd::PointF(tipX, tipY);
arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f);
arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size);
gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu,
(color >> 8) & 0xFFu, color & 0xFFu));
g.FillPolygon(&brush, arrow, 3);
}
}
PopupMenu::PopupMenu() = default;
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;
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
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 || 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;
const float height = totalHeight;
// 带说明文字的 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);
float cursorY = top + kPadV * m_scale;
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
const float itemHeight = ItemHeight(item);
m_itemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY,
std::max(0.0f, width - kPadH * 2.0f * m_scale), 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 = cursorY + (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;
}
}
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;
}
bool PopupMenu::OnEditMouseDown(int x, int y)
{
if (m_editIndex < 0)
{
return false;
}
return UiEditFieldMouseDown(m_fonts->uiSmall, EditFieldRect(m_editIndex), m_edit, m_scale, x, y);
}
bool PopupMenu::OnEditMouseMove(int x)
{
if (m_editIndex < 0)
{
return false;
}
return UiEditFieldMouseMove(m_fonts->uiSmall, EditFieldRect(m_editIndex), m_edit, m_scale, x);
}
bool PopupMenu::OnEditMouseUp()
{
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
bool PopupMenu::OnChar(wchar_t character, MenuResult& result)
{
result = MenuResult();
if (m_editIndex < 0)
{
return false;
}
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
switch (character)
{
case 0x03: // Ctrl+C
m_edit.CopyToClipboard(m_host);
return false;
case 0x18: // Ctrl+X
return m_edit.CutToClipboard(m_host);
case 0x16: // Ctrl+V
return m_edit.PasteFromClipboard(m_host);
case 0x01: // Ctrl+A
m_edit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
// 回车提交:菜单保持打开,仅把新数值回传宿主。
EndItemEdit(true, result);
return true;
}
// 退格/删除已由 WM_KEYDOWN 处理,此处忽略控制字符避免重复删除。
if (character < 0x20 || character == 0x7F)
{
return false;
}
return m_edit.Insert(character);
}
void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y,
const std::vector<MenuItem>& items, bool expandUp)
{
m_items = items;
if (m_items.empty())
{
Close();
return;
}
m_scope = scope;
m_open = true;
m_hoverIndex = -1;
m_hoverIconIndex = -1;
m_hoverSwatch = -1;
m_pressedIndex = -1;
m_pressedSwatch = -1;
m_buildBounds = bounds;
m_buildX = x;
m_buildY = y;
m_buildExpandUp = expandUp;
CloseSubmenu();
CloseEditState();
BuildLayout(bounds, x, y, expandUp);
}
void PopupMenu::Close()
{
m_open = false;
m_scope = MenuScope::NONE;
m_items.clear();
m_itemRects.clear();
m_swatchRects.clear();
m_hoverIndex = -1;
m_hoverIconIndex = -1;
m_hoverSwatch = -1;
m_pressedIndex = -1;
m_pressedSwatch = -1;
CloseSubmenu();
CloseEditState();
}
void PopupMenu::CloseEditState()
{
if (m_editIndex >= 0)
{
m_edit.End();
}
m_editIndex = -1;
}
bool PopupMenu::ContainsPoint(int x, int y) const
{
if (!m_open)
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (UiPointInRect(m_rect, fx, fy))
{
return true;
}
// 展开的二级子菜单同样属于菜单范围(否则在子菜单内点击会被当成“点菜单外”)。
return m_subOpen && UiPointInRect(m_subRect, fx, fy);
}
int PopupMenu::ItemAtPoint(int x, int y, int& swatchIndex) const
{
swatchIndex = -1;
if (!m_open)
{
return -1;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
if (item.kind == MenuItemKind::SEPARATOR || item.kind == MenuItemKind::HEADER)
{
continue;
}
if (!UiPointInRect(m_itemRects[index], fx, fy))
{
continue; }
if (item.kind == MenuItemKind::SWATCHES)
{
const std::vector<gd::RectF>& rects = m_swatchRects[index];
for (std::size_t swatch = 0; swatch < rects.size(); ++swatch)
{
if (UiPointInRect(rects[swatch], fx, fy))
{
swatchIndex = static_cast<int>(swatch);
return static_cast<int>(index);
}
}
// 落在色块行的空白处:整行视为可点击,用于定位提示。
return -1;
}
if (!item.enabled)
{
return -1;
}
return static_cast<int>(index);
}
return -1;
}
void PopupMenu::MoveHover(int step)
{
if (m_items.empty())
{
return;
}
const int count = static_cast<int>(m_items.size());
int index = m_hoverIndex;
for (int attempt = 0; attempt < count; ++attempt)
{
index += step;
if (index < 0)
{
index = count - 1;
}
else if (index >= count)
{
index = 0;
}
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
if ((item.kind == MenuItemKind::NORMAL || item.kind == MenuItemKind::EDIT) && item.enabled)
{
m_hoverIndex = index;
m_hoverSwatch = -1;
return;
}
}
}
bool PopupMenu::CommitHover(MenuResult& result)
{
if (m_hoverIndex < 0 || m_hoverIndex >= static_cast<int>(m_items.size()))
{
return false;
}
const MenuItem& item = m_items[static_cast<std::size_t>(m_hoverIndex)];
if (!item.enabled)
{
return false;
}
// 编辑项:回车等价于单击进入编辑,菜单不关闭。
if (item.kind == MenuItemKind::EDIT)
{
BeginItemEdit(m_hoverIndex);
return false;
}
if (item.kind != MenuItemKind::NORMAL)
{
return false;
}
result.valid = true;
result.scope = m_scope;
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = m_itemRects[static_cast<std::size_t>(m_hoverIndex)];
return true;
}
bool PopupMenu::OnMouseMove(int x, int y)
{
if (!m_open)
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
bool changed = false;
// 鼠标在展开的子菜单内:只更新子菜单的悬停项(主菜单父项保持高亮)。
if (m_subOpen && UiPointInRect(m_subRect, fx, fy))
{
const int sub = SubItemAtPoint(x, y);
if (sub != m_subHoverIndex)
{
m_subHoverIndex = sub;
changed = true;
}
return changed;
}
int swatchIndex = -1;
const int index = ItemAtPoint(x, y, swatchIndex);
const bool onSubmenuParent =
(index >= 0) && (m_items[static_cast<std::size_t>(index)].kind == MenuItemKind::SUBMENU);
// 子菜单展开期间,鼠标只要仍停留在「主菜单 ∪ 子菜单」的范围内且尚未落到其它可点击
// 条目上(例如掠过父项右缘与子菜单之间的内边距窄带、分隔线或禁用项),就不收起子菜单。
// 这样可避免处理器速度较快、鼠标移动事件过密时,用户还没把指针移到子菜单上,
// 子菜单就被中途误关闭;只有明确移到其它条目或完全离开该范围时才收起。
if (m_subOpen && !onSubmenuParent && index < 0 &&
(UiPointInRect(m_rect, fx, fy) || UiPointInRect(m_subRect, fx, fy)))
{
// 保持在父项高亮,视觉上指明子菜单的来源。
if (m_hoverIndex != m_subParentIndex || m_hoverSwatch != -1)
{
m_hoverIndex = m_subParentIndex;
m_hoverSwatch = -1;
changed = true;
}
return changed;
}
if (index != m_hoverIndex || swatchIndex != m_hoverSwatch)
{
m_hoverIndex = index;
m_hoverSwatch = swatchIndex;
changed = true;
}
// `HEADER_ACTION` 的图标按钮单独跟踪悬停状态(用于高亮,其余标题区域不响应)。
{
int iconHover = -1;
for (std::size_t itemIndex = 0; itemIndex < m_items.size(); ++itemIndex)
{
if (m_items[itemIndex].kind != MenuItemKind::HEADER_ACTION)
{
continue;
}
const gd::RectF icon = HeaderIconRect(static_cast<int>(itemIndex));
if (icon.Width > 0.0f && UiPointInRect(icon, fx, fy))
{
iconHover = static_cast<int>(itemIndex);
break;
}
}
if (iconHover != m_hoverIconIndex)
{
m_hoverIconIndex = iconHover;
changed = true;
}
}
// 悬停到带子菜单的项 → 向右展开;移开到其它条目或离开菜单 → 收起。
if (onSubmenuParent)
{
if (!m_subOpen || m_subParentIndex != index)
{
BuildSubLayout(index);
changed = true;
}
}
else if (m_subOpen)
{
CloseSubmenu();
changed = true;
}
return changed;
}
bool PopupMenu::OnLButtonDown(int x, int y, MenuResult& result)
{
result = MenuResult();
if (!m_open)
{
return false;
}
if (!ContainsPoint(x, y))
{
// 点击菜单外部:先提交正在编辑的数值,再关闭菜单。
EndItemEdit(true, result);
Close();
return true;
}
// 子菜单内的按下:记录待确认项,待抬起时提交。
if (m_subOpen && UiPointInRect(m_subRect, static_cast<float>(x), static_cast<float>(y)))
{
m_subPressedIndex = SubItemAtPoint(x, y);
m_pressedIndex = -1;
m_pressedSwatch = -1;
return true;
}
// 已经处于编辑状态时:点击当前输入框 → 重新定位插入符;
// 点击其它位置 → 先提交本次编辑(菜单不关闭),再按新位置继续处理。
if (m_editIndex >= 0)
{
if (IsOnEditField(x, y))
{
OnEditMouseDown(x, y);
m_pressedIndex = m_editIndex;
m_pressedSwatch = -1;
return true;
}
EndItemEdit(true, result);
}
int swatchIndex = -1;
m_pressedIndex = ItemAtPoint(x, y, swatchIndex);
m_pressedSwatch = swatchIndex;
return true;
}
bool PopupMenu::OnLButtonUp(int x, int y, MenuResult& result)
{
const MenuResult editResult = result;
result = MenuResult();
if (!m_open)
{
return false;
}
// 子菜单内的抬起:按下与抬起命中同一项才提交(菜单随之关闭)。
if (m_subOpen && m_subPressedIndex >= 0)
{
const int subPressed = m_subPressedIndex;
m_subPressedIndex = -1;
if (UiPointInRect(m_subRect, static_cast<float>(x), static_cast<float>(y)) &&
SubItemAtPoint(x, y) == subPressed)
{
const MenuItem& item = m_subItems[static_cast<std::size_t>(subPressed)];
if (item.kind == MenuItemKind::NORMAL || item.kind == MenuItemKind::SWATCHES)
{
result.valid = true;
result.scope = m_scope;
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = m_subItemRects[static_cast<std::size_t>(subPressed)];
Close();
}
}
return true;
}
// 编辑态下松开:先结束拖拽框选,不当作普通项点击。
if (m_editIndex >= 0)
{
OnEditMouseUp();
m_pressedIndex = -1;
m_pressedSwatch = -1;
result = editResult;
return true;
}
int swatchIndex = -1;
const int index = ItemAtPoint(x, y, swatchIndex);
const int pressedIndex = m_pressedIndex;
const int pressedSwatch = m_pressedSwatch;
m_pressedIndex = -1;
m_pressedSwatch = -1;
// 在菜单之外释放鼠标时视为取消操作。
if (!ContainsPoint(x, y))
{
Close();
return true;
}
if (index < 0 || index != pressedIndex)
{
return true;
}
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
// 子菜单父项:单击不下发命令,只保证子菜单处于展开状态。
if (item.kind == MenuItemKind::SUBMENU)
{
if (!m_subOpen || m_subParentIndex != index)
{
BuildSubLayout(index);
}
return true;
}
// `HEADER_ACTION`:只有**点在右侧图标按钮上**才下发命令(标题区域不响应);
// 菜单保持打开(这是一个可反复使用的小按钮,如「还原系数」)。
if (item.kind == MenuItemKind::HEADER_ACTION)
{
const gd::RectF icon = HeaderIconRect(index);
if (icon.Width > 0.0f && UiPointInRect(icon, static_cast<float>(x), static_cast<float>(y)))
{
result.valid = true;
result.scope = m_scope;
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = icon;
}
return true;
}
// 编辑项:单击进入菜单内联编辑,菜单保持打开。
if (item.kind == MenuItemKind::EDIT)
{
BeginItemEdit(index);
return true;
}
result.valid = true;
result.scope = m_scope;
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = m_itemRects[static_cast<std::size_t>(index)];
if (item.kind == MenuItemKind::SWATCHES)
{
if (swatchIndex < 0 || swatchIndex != pressedSwatch)
{
result.valid = false;
return true;
}
result.swatchIndex = swatchIndex;
}
// 带复选框的切换项(如镜像的水平/垂直)选择后**保持菜单打开**,便于连续勾选。
if (item.keepOpen)
{
return true;
}
Close();
return true;
}
bool PopupMenu::OnKeyDown(WPARAM key, MenuResult& result)
{
result = MenuResult();
if (!m_open)
{
return false;
}
// 编辑态下按键全部交给输入框(回车提交、Esc 取消本次编辑但菜单保持打开)。
if (m_editIndex >= 0)
{ const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
switch (key)
{
case VK_ESCAPE:
EndItemEdit(false, result);
return true;
case VK_RETURN:
EndItemEdit(true, result);
return true;
case VK_BACK:
m_edit.Backspace();
return true;
case VK_DELETE:
m_edit.DeleteForward();
return true;
case VK_LEFT:
m_edit.MoveCaret(-1, shift);
return true;
case VK_RIGHT:
m_edit.MoveCaret(1, shift);
return true;
case VK_HOME:
m_edit.MoveHome(shift);
return true;
case VK_END:
m_edit.MoveEnd(shift);
return true;
default:
break;
}
return true;
}
switch (key)
{
case VK_ESCAPE:
// Esc 先收起展开的二级子菜单,再次按下才关闭整个菜单。
if (m_subOpen)
{
CloseSubmenu();
return true;
}
Close();
return true;
case VK_LEFT:
if (m_subOpen)
{
CloseSubmenu();
return true;
}
break;
case VK_RIGHT:
{
if (m_hoverIndex >= 0 &&
m_items[static_cast<std::size_t>(m_hoverIndex)].kind == MenuItemKind::SUBMENU)
{
BuildSubLayout(m_hoverIndex);
return true;
}
break;
}
case VK_DOWN:
if (m_subOpen && m_subItems.size() > 0)
{
const int count = static_cast<int>(m_subItems.size());
m_subHoverIndex = (m_subHoverIndex + 1 + count) % count;
return true;
}
MoveHover(1);
return true;
case VK_UP:
if (m_subOpen && m_subItems.size() > 0)
{
const int count = static_cast<int>(m_subItems.size());
m_subHoverIndex = (m_subHoverIndex - 1 + count) % count;
return true;
}
MoveHover(-1);
return true;
case VK_RETURN:
{
// 子菜单展开且已有悬停项时,回车提交该项。
if (m_subOpen && m_subHoverIndex >= 0 &&
m_subHoverIndex < static_cast<int>(m_subItems.size()))
{
const MenuItem& item = m_subItems[static_cast<std::size_t>(m_subHoverIndex)];
if (item.kind == MenuItemKind::NORMAL && item.enabled)
{
result.valid = true;
result.scope = m_scope;
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = m_subItemRects[static_cast<std::size_t>(m_subHoverIndex)];
Close();
}
return true;
}
if (CommitHover(result))
{
Close();
}
return true;
}
default:
break;
}
return true;
}
void PopupMenu::Paint(gd::Graphics& g)
{
if (!m_open || m_theme == nullptr || m_fonts == nullptr || m_rect.Width <= 0.0f)
{
return;
}
// 投影:用两层递减透明度的圆角矩形模拟,避免引入系统阴影 API。
UiFillRoundRect(g, gd::RectF(m_rect.X + 1.0f * m_scale, m_rect.Y + 2.0f * m_scale, m_rect.Width,
m_rect.Height),
kCorner * m_scale, 0x14000000u);
UiFillRoundRect(g, gd::RectF(m_rect.X + 2.0f * m_scale, m_rect.Y + 4.0f * m_scale, m_rect.Width,
m_rect.Height),
kCorner * m_scale, 0x10000000u);
UiFillRoundRect(g, m_rect, kCorner * m_scale, m_theme->cardBg);
UiDrawRoundRect(g, m_rect, kCorner * m_scale, m_theme->borderStrong, 1.0f);
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
const gd::RectF& rect = m_itemRects[index];
if (item.kind == MenuItemKind::SEPARATOR)
{
const float lineY = rect.Y + rect.Height * 0.5f;
UiDrawLine(g, rect.X + kItemPad * m_scale, lineY,
rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f);
continue;
}
if (item.kind == MenuItemKind::HEADER)
{
// 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。
UiText(g, item.label, m_fonts->uiSmallBold,
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height),
m_theme->textMuted, TextAlign::LEFT);
continue;
}
if (item.kind == MenuItemKind::HEADER_ACTION)
{
// 标题 + 右侧「还原」图标按钮:文字与普通标题同一列;图标可点击(悬停高亮)。
const gd::RectF icon = HeaderIconRect(static_cast<int>(index));
const float labelWidth =
std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale);
UiText(g, UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold,
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, labelWidth, rect.Height),
m_theme->textMuted, TextAlign::LEFT);
if (icon.Width > 0.0f)
{
const bool iconHovered = (static_cast<int>(index) == m_hoverIconIndex);
if (iconHovered)
{
UiFillRoundRect(g, gd::RectF(icon.X - 2.0f * m_scale, icon.Y - 2.0f * m_scale,
icon.Width + 4.0f * m_scale, icon.Height + 4.0f * m_scale),
4.0f * m_scale, WithAlpha(m_theme->accent, 0x33));
}
PaintResetIcon(g, icon, iconHovered ? m_theme->accent : m_theme->text, m_scale);
}
continue;
}
const bool hovered = (static_cast<int>(index) == m_hoverIndex) &&
(item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0);
if (item.kind == MenuItemKind::EDIT)
{
// 编辑项:左侧说明文字 + 右侧内联输入框(与拾色器的 RGB 编辑同一种处理方式,
// 菜单始终不关闭,数值就地修改)。
if (hovered && item.enabled && static_cast<int>(index) != m_editIndex)
{
UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22));
}
const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted;
// 说明文字与普通项使用同一文字列(kItemPad),保证菜单内所有文本左对齐。
const float labelLeft = rect.X + kItemPad * m_scale;
const gd::RectF fieldRect = EditFieldRect(static_cast<int>(index));
const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale);
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall,
gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT);
const float suffix = EditSuffixWidth(item);
if (static_cast<int>(index) == m_editIndex)
{
// 编辑态:输入框底板覆盖整行,正文区向右让出后缀宽度,
// 后缀(单位提示)靠右对齐画在它右侧。
// ⚠️ 必须用 `UiDrawEditFieldContent`(只画内容)——`UiDrawEditField`
// 会把底板/描边按传入矩形绘制,那样带后缀时外框会短一截。
const gd::RectF textRect =
(suffix > 0.0f)
? gd::RectF(fieldRect.X, fieldRect.Y, std::max(12.0f, fieldRect.Width - suffix),
fieldRect.Height)
: fieldRect;
UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->cardBg);
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->accent, 1.6f);
UiDrawEditFieldContent(g, textRect, m_edit, m_fonts->uiSmall, m_scale);
if (suffix > 0.0f)
{
const float suffixLeft = textRect.X + textRect.Width;
UiText(g, item.editSuffix, m_fonts->uiSmall,
gd::RectF(suffixLeft, fieldRect.Y,
std::max(4.0f, fieldRect.X + fieldRect.Width - suffixLeft -
4.0f * m_scale),
fieldRect.Height),
m_theme->textMuted, TextAlign::RIGHT);
}
}
else
{
UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed);
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f);
const std::wstring text =
UiEllipsize(item.value, m_fonts->uiSmall, fieldRect.Width - 12.0f * m_scale);
UiText(g, text, m_fonts->uiSmall,
gd::RectF(fieldRect.X + 6.0f * m_scale, fieldRect.Y,
std::max(8.0f, fieldRect.Width - 12.0f * m_scale), fieldRect.Height),
item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT);
}
continue;
}
if (item.kind == MenuItemKind::SWATCHES)
{
const std::vector<gd::RectF>& rects = m_swatchRects[index];
for (std::size_t swatch = 0; swatch < rects.size(); ++swatch)
{
const gd::RectF& swatchRect = rects[swatch];
UiFillRoundRect(g, swatchRect, 4.0f * m_scale, item.swatches[swatch]);
const bool swatchHover = (static_cast<int>(index) == m_hoverIndex) &&
(static_cast<int>(swatch) == m_hoverSwatch);
if (static_cast<int>(swatch) == item.checkedSwatch || swatchHover)
{
const gd::RectF ring(swatchRect.X - 2.0f * m_scale, swatchRect.Y - 2.0f * m_scale,
swatchRect.Width + 4.0f * m_scale,
swatchRect.Height + 4.0f * m_scale);
UiDrawRoundRect(g, ring, 5.0f * m_scale,
(static_cast<int>(swatch) == item.checkedSwatch) ? m_theme->textStrong
: m_theme->borderStrong,
(static_cast<int>(swatch) == item.checkedSwatch) ? 1.8f : 1.0f);
}
}
continue;
}
// 普通项。
std::uint32_t background = 0;
if (hovered && item.enabled)
{
background = m_theme->accent;
}
else if (static_cast<int>(index) == m_pressedIndex)
{
background = m_theme->pressed;
}
if (background != 0)
{
UiFillRoundRect(g, rect, 4.0f * m_scale, background);
}
const std::uint32_t textColor = !item.enabled ? m_theme->textMuted
: ((hovered && item.enabled) ? m_theme->textOnAccent
: m_theme->text);
if (item.checked)
{
// 单选标记:对勾固定画在条目**右侧**的标记列内,左侧只留统一的小边距,
// 这样菜单文本左边距不会因为“可能存在的对勾”而被整体推右。
const float markerRight = rect.X + rect.Width - kItemPad * m_scale;
const float cx = markerRight - kMarkerColumn * m_scale * 0.5f;
const float cy = rect.Y + rect.Height * 0.5f;
const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent;
UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale,
markerColor, 1.8f * m_scale);
UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale,
cy - 4.0f * m_scale, markerColor, 1.8f * m_scale);
}
// 带子菜单的项:在右侧标记列画一个指向右的三角,提示可以继续展开。
if (item.kind == MenuItemKind::SUBMENU)
{
const float markerRight = rect.X + rect.Width - kItemPad * m_scale;
const float cx = markerRight - kMarkerColumn * m_scale * 0.5f;
const float cy = rect.Y + rect.Height * 0.5f;
const float size = kSubArrowSize * m_scale;
gd::PointF arrow[3];
arrow[0] = gd::PointF(cx - size * 0.5f, cy - size);
arrow[1] = gd::PointF(cx - size * 0.5f, cy + size);
arrow[2] = gd::PointF(cx + size * 0.7f, cy);
gd::SolidBrush brush(ToGdiColor(textColor));
g.FillPolygon(&brush, arrow, 3);
}
const float textLeft = rect.X + kItemPad * m_scale;
const float textWidth =
rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale;
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor,
TextAlign::LEFT);
}
// 二级子菜单最后绘制,保证覆盖在主菜单之上。
PaintSubmenu(g);
}
void PopupMenu::PaintSubmenu(gd::Graphics& g)
{
if (!m_subOpen || m_subRect.Width <= 0.0f || m_subRect.Height <= 0.0f)
{
return;
}
UiFillRoundRect(g, gd::RectF(m_subRect.X + 1.0f * m_scale, m_subRect.Y + 2.0f * m_scale,
m_subRect.Width, m_subRect.Height),
kCorner * m_scale, 0x14000000u);
UiFillRoundRect(g, gd::RectF(m_subRect.X + 2.0f * m_scale, m_subRect.Y + 4.0f * m_scale,
m_subRect.Width, m_subRect.Height),
kCorner * m_scale, 0x10000000u);
UiFillRoundRect(g, m_subRect, kCorner * m_scale, m_theme->cardBg);
UiDrawRoundRect(g, m_subRect, kCorner * m_scale, m_theme->borderStrong, 1.0f);
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const MenuItem& item = m_subItems[index];
const gd::RectF& rect = m_subItemRects[index];
if (item.kind == MenuItemKind::SEPARATOR)
{
const float lineY = rect.Y + rect.Height * 0.5f;
UiDrawLine(g, rect.X + kItemPad * m_scale, lineY,
rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f);
continue;
}
if (item.kind == MenuItemKind::HEADER)
{
UiText(g, item.label, m_fonts->uiSmallBold,
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height),
m_theme->textMuted, TextAlign::LEFT);
continue;
}
const bool hovered = (static_cast<int>(index) == m_subHoverIndex) && item.enabled;
if (item.kind == MenuItemKind::EDIT)
{
// 子菜单中的编辑项:与主菜单一样就地编辑。
if (hovered && static_cast<int>(index) != m_editIndex)
{
UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22));
}
const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted;
const float labelLeft = rect.X + kItemPad * m_scale;
const gd::RectF fieldRect = EditFieldRect(static_cast<int>(index));
const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale);
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall,
gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT);
UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed);
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f);
const std::wstring text =
UiEllipsize(item.value, m_fonts->uiSmall, fieldRect.Width - 12.0f * m_scale);
UiText(g, text, m_fonts->uiSmall,
gd::RectF(fieldRect.X + 6.0f * m_scale, fieldRect.Y,
std::max(8.0f, fieldRect.Width - 12.0f * m_scale), fieldRect.Height),
item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT);
continue;
}
if (item.kind == MenuItemKind::SWATCHES)
{
continue;
}
std::uint32_t background = 0;
if (hovered)
{
background = m_theme->accent;
}
else if (static_cast<int>(index) == m_subPressedIndex)
{
background = m_theme->pressed;
}
if (background != 0)
{
UiFillRoundRect(g, rect, 4.0f * m_scale, background);
}
const std::uint32_t textColor = !item.enabled ? m_theme->textMuted
: (hovered ? m_theme->textOnAccent
: m_theme->text);
// 选中标记(对勾):与主菜单一致画在右侧标记列内。
if (item.checked)
{
const float markerRight = rect.X + rect.Width - kItemPad * m_scale;
const float cx = markerRight - kMarkerColumn * m_scale * 0.5f;
const float cy = rect.Y + rect.Height * 0.5f;
const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent;
UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale,
markerColor, 1.8f * m_scale);
UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale,
cy - 4.0f * m_scale, markerColor, 1.8f * m_scale);
}
const float textLeft = rect.X + kItemPad * m_scale;
const float textWidth =
rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale;
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor,
TextAlign::LEFT);
}
}
} // namespace jdi