Files
Just-DataInspector/Components/Shared/Src/PopupMenu.cpp
T
DeepSeek V4.1 Flash 53b981a2a9 全项目大检查:修复数据层/UI 真 BUG、清理死代码与过期注释、并评估二进制体积
按「性能 / BUG / 错误 / 过期注释 / 命名与实际含义不符」四项对全仓做了一次审查,
四个只读子审查交叉核对后逐条回到源码确认再改。

一、数据层真 BUG
- MatH5:U8/U16/U32/U64 用逐字节 Cached() 拼装,而 Cached 只服务「完全落在同一个
  64KB 块内」的请求 => 跨块时静默读成 0(U32 整段为 0、U64 高 32 位为 0),分块 B-tree
  的 chunk 偏移/子节点地址被破坏且无任何报错。新增 ScalarLE():命中缓存用缓存,
  否则退回一次 ReadAt;U64 顺带从 8 次缓存查找降到 1 次。
- Mf4Loader::IsBlock 未校验链接表是否落在块内 => DataOffset 越界,随后
  block_len - (data - offset) 在 uint64 上下溢,得到一个约 2^64 的"可用字节数",
  下游 vector(usable+1) 直接 bad_alloc。同时把 offset + bytes > size 改成不产生回绕的写法。
- DecodeRawValue 的整数路径:bit_offset%8 != 0 且位宽 64 时需 9 个字节,先拼成 64 位
  再整体右移会把最高字节静默丢弃 => 改为逐字节带位偏移累加。
- cg_cycle_count 加「数据段总字节 / 记录字节」上限(压缩段按 deflate 最大膨胀比估算)。
- CsvLoader 表头分支同样跳过 #/% 注释行(否则整份文件按 1 列解析并被丢弃)。
- MatLoader:double -> int 的越界转换加了范围检查;普通对象递归加深度上限与成环检测。
- Utf8Util::BuildTicks:double -> long long 越界转换前先判商是否落在 ±9e18 内。

二、界面 BUG
- 工具栏按钮永远不显示悬停高亮:Paint 读的 Button::hover 全仓从未赋值,改为按 m_hoverIndex 判断。
- 菜单打开时"取消底层拖拽"对每个消息都执行,把菜单自己的 Target::EDIT_FIELD 也清掉
  => 菜单内联输入框无法拖拽框选。排除 EDIT_FIELD。
- GdiplusShutdown 之前未释放 PlotView 的 GDI+ 位图(Application 是进程单例,成员析构在
  WinMain 之后)=> 新增 PlotView::ReleaseGraphicsResources() 并在 Shutdown 里调用。
- WM_KILLFOCUS 现在会提交标签页/绘图区编辑并复位捕获与拖拽状态。
- WM_LBUTTONUP 不再抢走中键平移的鼠标捕获。
- TextEdit:被过滤器拒绝的字符返回 false;无选区时 Ctrl+X 只复制、不再清空整段文本。
- SignalPanel::InvalidateRows 作废编辑/待确认/改名计时等行下标状态。
- EndFilterEdit 改走 ApplyFilterText(否则输入纯空格后回车,列表仍停在筛选结果)。
- 新增 SignalBadgeVisible 作为唯一判据(原来命中只看"可见",绘制还要求"已分配网格")。
- 折起动画结束后夹取滚动量;双击分组名改名时清掉该组正在播放的折起动画。
- ScrollToSignal 复用 SignalScrollTarget(含悬浮组名避让)。
- RowNameRect 与绘制使用同一最小宽度(原来退化时返回空矩形)。
- 分隔条拖拽上限改用与 Relayout 同一公式;UpdateDragCursor 补 SPLITTER/EDIT_FIELD。
- SignalPanel 里几个守卫改为判断真正被解引用的指针;分组行 ✕ 也返回手型光标。

三、PlotView
- FindStyleOnlyLanes 改用完整的 SceneStyleEntry::operator==(原来只比 4 个字段,
  另一个网格同时改"调整"时带着旧像素被当成最新)。
- 宽基底图层不再烘焙拖拽光晕(LaneLayerHash 不含它),改由 PaintHoverHighlight 每帧叠加。
- PaintLaneLegend 提前返回前清掉本网格的命中矩形(否则能点中看不见的图例条目)。
- 面板主体改为完全不透明(消除平移后 4% 透出的旧波形,零额外代价)。
- PAN / Shift+滚轮改走 SetTimeView,使"自动点线"判据随平移重算。
- 内联输入框命中字体与绘制统一为 monoSmall。
- 派生曲线取值与窗口按 SourceIndexFor 映射(水平镜像时不再读到镜像另一端)。
- EdgePanelWidth 补 style/axis 空指针守卫;二分结果夹到 count。

四、清理
- 删除 Toolbar::ActionLabel、Theme::statusBg、PopupMenu 的 editResult 死机制、
  Toolbar::Layout 的重复 clear()、HitRow 的恒假分支、OnDoubleClick 的重复判定、
  PaintUtil 里孤立的注释块。
- 修正一批过期/错误注释(包络带"不做加宽"、LaneLayerHash 的图例说法、
  均值滤波曲线的窗口说法、样式区行数与「调整」按钮顺序、FIT_ALL 退出缩放模式、
  RemoveSignal 的 ✕ 位置、.mat/.dat 格式文档、MatH5 的"映射视图"、DataModel 的
  "点线/被移除文件"、SignalPanel 的"行高连续缩小"、TextEdit 的 PaintUtil 引用等)。

五、二进制体积评估(按要求只评估,未做压缩修改)
- release 809,472 字节;.text 652KB、.xdata+.pdata 约 75KB、.rdata 42KB、
  .rsrc 20KB、.idata 11.5KB。
- 去掉 -ld3d11 -ldwmapi -ld3dcompiler(仓内无任何调用)实测 0 字节收益
  (静态库只拉取被引用的对象)=> 删链接库不是压缩手段,只是配置整洁。
- 仓内已无 <iostream>/<sstream>/<filesystem>/<regex>/<locale> 等体积炸弹。
- 结论:已无明显的浪费可捡;要显著变小只能改变取舍(动态 CRT / UPX / 砍功能),
  需要用户决策,本轮未动。

验证
- editcheck 20/20、round3check 31/31、round4check 34/34、round5check 5/5、convcheck 18/18。
- check 136/137:唯一失败项经 git stash 回到改动前复现同一数值,属该探针自身的既有
  不稳定项(取决于前置用例留下的缓存/时序),与本轮改动无关。
- 真实数据:4 个 MF4 => 283/8/18/24 个信号;.mat 样例 8 信号、
  带无障碍对照-防夹力曲线数据 8 信号、BIG_MAT.mat 5 信号(私有内存 +741MB)。
- make release / make debug 零警告。

文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 171-180 条硬约束
与文末本轮小节(含体积评估数据)。
2026-10-20 16:10:29 +08:00

1902 lines
63 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 "UiControls.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; /**< 整行输入框(无说明文字)的默认宽度。 */
const float kScrollBarWidth = 6.0f; /**< 可滚动菜单的滚动条宽度。 */
const float kScrollBarGap = 4.0f; /**< 滚动条与条目内容之间的间距。 */
const float kScrollBarMinThumb = 22.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;
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;
}
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,
const MenuLayoutConstraint& constraint)
{
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;
// 每次打开都从顶部开始(滚动位置不跨次沿用)。
m_constraint = constraint;
m_scrollOffset = 0.0f;
m_scrollBarDragging = false;
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;
m_constraint = MenuLayoutConstraint();
m_scrollFirst = 0;
m_pinnedIndex = -1;
m_scrollEnabled = false;
m_scrollOffset = 0.0f;
m_scrollRegionHeight = 0.0f;
m_scrollViewport = gd::RectF();
m_scrollBarRect = gd::RectF();
m_scrollBarDragging = false;
m_scrollBarGrabOffset = 0.0f;
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 (m_scrollEnabled && static_cast<int>(index) >= m_scrollFirst &&
static_cast<int>(index) < m_pinnedIndex && !UiPointInRect(m_scrollViewport, 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;
// 键盘移动时把目标条目滚入可视区(长菜单里按方向键不能“跳到看不见的条目”)。
EnsureItemVisible(index);
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_scrollBarDragging)
{
const float previous = m_scrollOffset;
m_scrollOffset = ScrollOffsetForThumbTop(fy - m_scrollBarGrabOffset);
RebuildLayout();
return std::fabs(m_scrollOffset - previous) > 0.01f;
}
// 鼠标在展开的子菜单内:只更新子菜单的悬停项(主菜单父项保持高亮)。
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);
}
// 可滚动菜单的滚动条:按下即开始拖拽(不是条目点击,菜单保持打开)。
if (m_scrollEnabled)
{
const gd::RectF thumb = ScrollBarThumbRect();
if (thumb.Width > 0.0f && UiPointInRect(thumb, static_cast<float>(x), static_cast<float>(y)))
{
// 抓住滑块:记录抓取偏移,拖动时按“指针 − 偏移”重算偏移量。
m_scrollBarDragging = true;
m_scrollBarGrabOffset = static_cast<float>(y) - thumb.Y;
m_pressedIndex = -1;
m_pressedSwatch = -1;
RebuildLayout();
return true;
}
if (UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
{
// 点在轨道空白处:把滑块中心移到该处(翻页)。
m_scrollBarDragging = true;
m_scrollBarGrabOffset = thumb.Height * 0.5f;
m_scrollOffset = ScrollOffsetForThumbTop(static_cast<float>(y) - m_scrollBarGrabOffset);
m_pressedIndex = -1;
m_pressedSwatch = -1;
RebuildLayout();
return true;
}
}
int swatchIndex = -1;
m_pressedIndex = ItemAtPoint(x, y, swatchIndex);
m_pressedSwatch = swatchIndex;
return true;
}
bool PopupMenu::OnLButtonUp(int x, int y, MenuResult& result)
{
// 每次抬起都重新构造结果(编辑提交已在 `OnLButtonDown` 里通过 `EndItemEdit` 下发,
// 这里不需要保留进入时的旧结果)。
result = MenuResult();
if (!m_open)
{
return false;
}
// 结束滚动条拖拽(不当作条目点击,菜单保持打开)。
if (m_scrollBarDragging)
{
m_scrollBarDragging = false;
m_pressedIndex = -1;
m_pressedSwatch = -1;
return true;
}
// 子菜单内的抬起:按下与抬起命中同一项才提交(菜单随之关闭)。
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;
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);
// 条目文字统一走本 lambda:可滚动区里的条目必须夹到**滚动视口** ——
// `UiText` 走 GDI `DrawTextW`,**不遵循 GDI+ 的 `SetClip`**,不显式裁剪就会画到固定区上。
//(GDI+ 图元 —— 行底色 / 圆角 / 分隔线 / 对勾 / 子菜单三角 —— 受 `SetClip` 约束,无需额外处理。)
const auto itemText = [this, &g](const std::wstring& text, HFONT font, const gd::RectF& rect,
std::uint32_t argb, TextAlign align, bool clipped)
{
if (clipped)
{
UiTextClipped(g, text, font, rect, argb, align, m_scrollViewport);
}
else
{
UiText(g, text, font, rect, argb, align);
}
};
// 可滚动菜单的条目分三段:
// * `[0, m_scrollFirst)`:**前导标题**(「所属网格」这类)—— 钉在菜单顶部,**不参与滚动**;
// * `[m_scrollFirst, m_pinnedIndex)`:滚动区(裁剪到滚动视口,右侧有滚动条);
// * `[m_pinnedIndex, end)`:末尾固定项(最后一个分隔线 + 「新建网格」)钉在菜单底部。
const bool scrollable = m_scrollEnabled && m_pinnedIndex > 0;
const int scrollFirst = scrollable ? m_scrollFirst : static_cast<int>(m_items.size());
const int scrollLast = scrollable ? m_pinnedIndex : static_cast<int>(m_items.size());
int clipState = 0;
bool clipActive = false;
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
const gd::RectF& rect = m_itemRects[index];
const int itemIndex = static_cast<int>(index);
// 进入/离开滚动区:按需开启/撤销 GDI+ 裁剪(固定区永远完整可见)。
if (scrollable && itemIndex == scrollFirst && !clipActive)
{
clipState = g.Save();
g.SetClip(m_scrollViewport, gd::CombineModeIntersect);
clipActive = true;
}
else if (scrollable && itemIndex == scrollLast && clipActive)
{
g.Restore(clipState);
clipActive = false;
}
const bool inScrollRegion = itemIndex >= scrollFirst && itemIndex < scrollLast;
// 滚动区里完全不可见的条目直接跳过:长菜单(上百条)逐条绘制会明显卡顿。
if (scrollable && inScrollRegion &&
(rect.Y + rect.Height <= m_scrollViewport.Y ||
rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height))
{
continue;
}
const bool clipped = scrollable && inScrollRegion;
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)
{
// 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。
itemText(item.label, m_fonts->uiSmallBold,
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height),
m_theme->textMuted, TextAlign::LEFT, clipped);
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);
itemText(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, clipped);
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);
itemText(UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall,
gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT,
clipped);
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;
itemText(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, clipped);
}
}
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);
itemText(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, clipped);
}
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;
itemText(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, clipped);
}
// 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。
if (clipActive)
{
g.Restore(clipState);
clipActive = false;
}
// 滚动条:轨道 + 滑块(画在条目之上,仅在可滚动菜单里出现)。
// 位置限制在可滚动条目区旁边,因此不会盖住顶部标题与底部「新建网格」。
if (scrollable && m_scrollBarRect.Width > 0.0f)
{
const float radius = kScrollBarWidth * m_scale * 0.5f;
UiFillRoundRect(g, m_scrollBarRect, radius, m_theme->gridWeak);
const gd::RectF thumb = ScrollBarThumbRect();
if (thumb.Width > 0.0f)
{
UiFillRoundRect(g, thumb, radius, m_theme->borderStrong);
}
}
// 二级子菜单最后绘制,保证覆盖在主菜单之上。
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