## 为什么
用户 2026-10-29 报告/要求五件事:
1. 派生信号的「时间轴偏移保留在样式上、其它调整烘值」维持现状(确认无需改动);
2. 信号名右键菜单里很多功能已被下方样式自定义区覆盖 ⇒ 只保留「仅显示该信号」,
并在它**上方**新增「克隆该信号」;
3. 设置菜单新增「主题配色」二级菜单(带 √ 状态),顺序 Light / Solarised Light /
Dark / Solarised Dark,默认主题维持现状;
4. 布局边界:中键平移视界时浮动面板上的边界**闪烁**;光标取值胶囊的**文字**框会**穿透**
到浮动面板上;并请核对网格内固定构件(图例 / Y 轴标尺 / GX 标签 / 回到最上方 /
浮动面板)的层级关系;
5. 单数据点信号在拖动光标时的**吸附**功能消失。
## 根因
### 布局边界层级(两个都很隐蔽)
- **文本层号写死**:文本是在绘制过程中由 `UiTextRectSink` 收集的,而宿主把所有文本都记在
固定层 2;组件几何层却是"组件表下标"(`PlotView` = 0)。面板层 = PlotView 层 + 1 = 1,
**低于**文本层 2 ⇒ 面板的遮挡矩形挖不到文本 ⇒ 胶囊文字画在面板表面。
- **面板像素缓存**:面板"先画进图层、命中缓存就贴图",贴图那一帧不画字 ⇒ 文本框凭空消失
⇒ 面板上的边界闪烁。
- 顺带发现 `PlotView::BuildBoxes` 用 `PushLayer()` 却**从不复位**,会把宿主后续所有组件的
层号整体推高。
### 单点信号吸附
`MinorCellSparse` 在"把判定范围收窄到数据实际时间跨度"的分支里有
`if (!(dataMax > dataMin)) return false;`。单个有效采样点时 `dataMax == dataMin`,
于是被判定成"不稀疏" ⇒ 不吸附。
## 改了什么
### 1. 布局边界层级
- `UiTextRectSink` 新增 `OnTextLayer(int)` / `TextLayer()`;新增 `UiSetTextLayer` /
`UiGetTextLayer`;`UiBoxTable` 实现为改 `m_layer`,并暴露 `Layer()`。
- `Application::Render` 在绘制每个组件前下发 `UiSetTextLayer(ComponentLayer(&component))`,
浮层用 `OverlayLayer(1/2)`;新增 `ComponentLayer()` / `OverlayLayer()` /
`kLayersPerComponent = 3`,与 `PaintLayoutBounds` 的层号**完全同源**。
- `PaintLayoutBounds` 改为**显式** `SetLayer(下标 × 3)`(不再依赖 `PushLayer` 累加)。
- `PlotView`:`BuildBoxes` 用 `baseLayer + 1` 放浮动面板(并登记遮挡)、
`baseLayer + 2` 放绘制在面板之上的滚动条 / 回到最上方 / 输入框,结束**复位**层号;
`paintOverlays` 同步分两次抬高文本层。
- 面板像素图层新增 `PanelSlot::textRects` + 转发型收集器 `PanelTextCollector`:
渲染时存文本框,命中缓存贴图时回放 ⇒ 几何与缓存命中无关(消除闪烁)。
### 2. 信号右键菜单 + 克隆
- 菜单只剩:标题、**克隆该信号**、**仅显示该信号**;删除显示/隐藏、显示全部/隐藏全部、
预设色块行、样式三项,以及对应的 `CMD_SIGNAL_TOGGLE/SHOW_ALL/HIDE_ALL`、
`CMD_COLOR_BASE`、`CMD_MODE_BASE` 及其分支(死代码)。
- 新增 `CMD_SIGNAL_CLONE`(post `WM_APP + 5`)→ `Application::CloneSignal`:
把「调整」(系数 / 绝对值 / 镜像 / Y 偏移)烘进采样值、**时间轴偏移保留在样式上**,
名称前置 `*`,插入到源信号**正下方**,并**保留原外观**(颜色/透明度/线宽/绘制样式)。
- 抽出 `Application::InsertSignalBelowSource(...)` 供派生与克隆共用。
### 3. 主题配色
- `ThemeKind{LIGHT, SOLARIZED_LIGHT, DARK, SOLARIZED_DARK}` + `ThemeFor()` / `ThemeName()`;
Light 取 VS Code 2026 默认浅色、Dark 取 Dark+、Solarized Dark 取 VS Code solarized dark。
**默认仍是 Solarised Light**。
- 新增 `CurrentTheme()` / `SetCurrentTheme()`:宿主交出自己的 `m_theme` 地址,
切换主题只改内容;`UiControls` 里没有 `Theme` 形参的原语改用它(原来硬编码
`SolarizedLight()`,换主题不生效)。
- `Application::ApplyTheme()`:改配色 → `PlotView::OnThemeChanged()`(作废场景/宽基底/
装饰/光晕/面板图层)+ `SignalPanel::OnThemeChanged()`(作废行位图)→ 整窗重绘。
- 菜单用 `SUBMENU` + 4 个 `keepOpen` 子项;新增 `PopupMenu::UpdateSubmenuItemLabel()`
回写勾选,并让子菜单分支尊重 `keepOpen`(原为无条件关闭)。
### 4. 单点信号吸附
`MinorCellSparse` 改为只在 `dataMax < dataMin` 时放弃;相等时按"那一个小格"判定
(`need = 5 > finiteCount = 1` ⇒ 稀疏 ⇒ 吸附恢复)。
### 5. 审计
`Makefile` 的 `LAYOUT_BOUNDS` / `JDI_LAYOUT_BOUNDS` **从未被任何 `#if` 引用**
(`LAYOUT_BOUNDS=0` 产物字节数完全相同),属名实不符的死开关 ⇒ 删除并注明原因。
## 怎么验证
新增两支离屏探针(不占鼠标 / 键盘 / 屏幕):
```
.preflight/round15check 28 passed, 0 failed
单点吸附 7 条(含"远处不吸附")、面板层级 5 条、面板文本框跨帧与中键平移稳定 2 条、
克隆 14 条(烘值逐点比对 / 名称前缀 / 偏移与外观保留 / 调整复位)
.preflight/themecheck 17 passed, 0 failed
4 套主题可读性与可区分、默认 = Solarised Light、逐主题换色且无旧底色残留、
切回默认逐像素还原(diff = 0)、子菜单 4 项 / 顺序 / 唯一勾选 / 命令切换 / 菜单保持打开
```
回归全绿:
```
uicheck 69/69 lbcheck 21/21 lbresid 5/5 lbzorder 4/4
lbcovr 7/7 lbpanel 10/10 derivecheck 16/16
rendercheck: 8 个场景哈希与上一轮逐一相同 + FUNC 7/7, exit=0
A FAED59DF284153DB B E6443BFE4E3955F8 C 96A0AA8C402F1729 D E3D5DBD998E43720
E DA0583CB13C26A6F F FFB822159F604D1D G 19F8DE8FE3A8DF18 H DBBDDE4CB2C97C92
```
`make release` / `make debug` 零 error / 零 warning;产物 **791,552 B**
(单一 exe,未拆 DLL,未改 `-Oz`;离 1MB 预警线约 250 KB)。
788 lines
27 KiB
C++
788 lines
27 KiB
C++
// PopupMenu 实现分片 —— 几何与排版(BuildLayout / 子菜单 / 滚动区 / 内联输入框位置)。
|
||
//
|
||
// 本文件只包含 PopupMenu 的成员函数定义(从 PopupMenu.cpp 机械搬移,行为不变)。
|
||
//
|
||
// ⚠️ 这里是菜单**唯一**的几何来源:`Paint` 与所有命中测试都读它产出的矩形,
|
||
// 不得在别处再反推一遍(否则会出现"看得见点不到")。
|
||
// ⚠️ `NaturalEditFieldWidth` / `EditSuffixWidth` 同时参与排版与绘制,改公式必须两处一致。
|
||
|
||
#include "PopupMenu.hpp"
|
||
|
||
#include "PopupMenuInternal.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
void PopupMenu::Setup(const UiFonts* fonts, const Theme* theme)
|
||
{
|
||
m_fonts = fonts;
|
||
m_theme = theme;
|
||
}
|
||
|
||
void PopupMenu::SetScale(float scale)
|
||
{
|
||
m_scale = (scale > 0.1f) ? scale : 1.0f;
|
||
}
|
||
|
||
float PopupMenu::ItemHeight(const MenuItem& item) const
|
||
{
|
||
switch (item.kind)
|
||
{
|
||
case MenuItemKind::SEPARATOR:
|
||
return kSeparatorHeight * m_scale;
|
||
case MenuItemKind::HEADER:
|
||
case MenuItemKind::HEADER_ACTION:
|
||
return kHeaderHeight * m_scale;
|
||
case MenuItemKind::SWATCHES:
|
||
return kSwatchRowHeight * m_scale;
|
||
case MenuItemKind::EDIT:
|
||
// 编辑项比普通项略高,给输入框留出上下呼吸空间。
|
||
return (kRowHeight + 4.0f) * m_scale;
|
||
case MenuItemKind::NORMAL:
|
||
default:
|
||
return kRowHeight * m_scale;
|
||
}
|
||
}
|
||
|
||
void PopupMenu::BuildLayout(const gd::RectF& bounds, int x, int y, bool expandUp)
|
||
{
|
||
m_itemRects.assign(m_items.size(), gd::RectF());
|
||
m_swatchRects.assign(m_items.size(), std::vector<gd::RectF>());
|
||
|
||
if (m_fonts == nullptr)
|
||
{
|
||
m_rect = gd::RectF();
|
||
return;
|
||
}
|
||
|
||
float contentWidth = kMinWidth * m_scale;
|
||
float totalHeight = kPadV * 2.0f * m_scale;
|
||
|
||
std::vector<float> heights(m_items.size(), 0.0f);
|
||
for (std::size_t index = 0; index < m_items.size(); ++index)
|
||
{
|
||
const MenuItem& item = m_items[index];
|
||
heights[index] = ItemHeight(item);
|
||
totalHeight += heights[index];
|
||
|
||
if (item.kind == MenuItemKind::SWATCHES)
|
||
{
|
||
const int count = std::max(0, std::min(item.swatchCount, 16));
|
||
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
|
||
static_cast<float>(count) * kSwatchSize * m_scale +
|
||
static_cast<float>(std::max(0, count - 1)) * kSwatchGap * m_scale;
|
||
contentWidth = std::max(contentWidth, width);
|
||
}
|
||
else if (item.kind == MenuItemKind::HEADER || item.kind == MenuItemKind::HEADER_ACTION)
|
||
{
|
||
// 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。
|
||
const float iconExtra = (item.kind == MenuItemKind::HEADER_ACTION)
|
||
? (kHeaderIconSize + kHeaderIconGap) * m_scale
|
||
: 0.0f;
|
||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||
kMarkerGap * m_scale + iconExtra +
|
||
UiMeasureTextWidth(m_fonts->uiSmallBold, item.label);
|
||
contentWidth = std::max(contentWidth, width);
|
||
}
|
||
else if (item.kind == MenuItemKind::NORMAL)
|
||
{
|
||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||
kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label);
|
||
contentWidth = std::max(contentWidth, width);
|
||
}
|
||
else if (item.kind == MenuItemKind::EDIT)
|
||
{
|
||
// 说明文字 + 输入框;输入框宽度按数值文本自适应(与 EditFieldRect 保持一致)。
|
||
const float fieldWidth = NaturalEditFieldWidth(item);
|
||
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
|
||
UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale +
|
||
fieldWidth;
|
||
contentWidth = std::max(contentWidth, width);
|
||
}
|
||
}
|
||
|
||
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
|
||
const float width = contentWidth;
|
||
|
||
// ------------------------------------------------------------------
|
||
// 超长菜单:限制高度 + 条目区可滚动 + 顶部/尾部固定项(见 `MenuLayoutConstraint`)。
|
||
// * 顶部固定 = **前导的 `HEADER` / `HEADER_ACTION`**(典型:「所属网格」标题)——
|
||
// 用户 2026-10-16 要求:滚动时标题必须留在最上方不动;
|
||
// * 尾部固定 = **最后一个分隔线及其之后的条目**(典型:「新建网格」)钉在底部;
|
||
// * 中间是滚动区(鼠标滚轮 / 键盘 / 滚动条),右侧画一根滚动条。
|
||
// ------------------------------------------------------------------
|
||
int pinnedFrom = -1;
|
||
int scrollFirst = 0;
|
||
if (m_constraint.maxHeight > 0.0f || m_constraint.maxScrollRows > 0)
|
||
{
|
||
for (int index = static_cast<int>(m_items.size()) - 1; index > 0; --index)
|
||
{
|
||
if (m_items[static_cast<std::size_t>(index)].kind == MenuItemKind::SEPARATOR)
|
||
{
|
||
pinnedFrom = index;
|
||
break;
|
||
}
|
||
}
|
||
while (scrollFirst < pinnedFrom &&
|
||
(m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER ||
|
||
m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER_ACTION))
|
||
{
|
||
++scrollFirst; // 前导标题固定在顶部,不参与滚动
|
||
}
|
||
}
|
||
|
||
float headHeight = 0.0f;
|
||
float scrollRegionHeight = 0.0f;
|
||
float tailHeight = 0.0f;
|
||
if (pinnedFrom > 0)
|
||
{
|
||
for (int index = 0; index < static_cast<int>(m_items.size()); ++index)
|
||
{
|
||
if (index >= pinnedFrom)
|
||
{
|
||
tailHeight += heights[static_cast<std::size_t>(index)];
|
||
}
|
||
else if (index < scrollFirst)
|
||
{
|
||
headHeight += heights[static_cast<std::size_t>(index)];
|
||
}
|
||
else
|
||
{
|
||
scrollRegionHeight += heights[static_cast<std::size_t>(index)];
|
||
}
|
||
}
|
||
}
|
||
|
||
float height = totalHeight;
|
||
m_scrollEnabled = false;
|
||
m_scrollRegionHeight = 0.0f;
|
||
m_scrollFirst = 0;
|
||
m_pinnedIndex = -1;
|
||
m_scrollViewport = gd::RectF();
|
||
m_scrollBarRect = gd::RectF();
|
||
// ⚠️ **不要在这里复位 `m_scrollBarDragging`**:拖拽滚动条时每一帧都会 `RebuildLayout()`,
|
||
// 在这里清零会把拖拽态当场吃掉(拖一下断一下)。该状态只在 `Open` / `Close` 里复位。
|
||
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写 / 拖滚动条)
|
||
// 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要**
|
||
// 滚动时才复位(`Open` 里已经为每次打开重置过)。
|
||
if (pinnedFrom > 0)
|
||
{
|
||
// 滚动视口高度上限:`maxHeight`(客户区像素 → 换算成视口可用高)与
|
||
// `maxScrollRows`(标准行数)**取更严格的那个**;0 = 该约束不生效。
|
||
float viewportLimit = 0.0f;
|
||
if (m_constraint.maxHeight > 0.0f)
|
||
{
|
||
// 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”,
|
||
// 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。
|
||
const float menuLimit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale);
|
||
if (menuLimit >= 40.0f * m_scale)
|
||
{
|
||
viewportLimit = std::max(
|
||
0.0f, menuLimit - kPadV * 2.0f * m_scale - headHeight - tailHeight);
|
||
}
|
||
}
|
||
if (m_constraint.maxScrollRows > 0)
|
||
{
|
||
const float rows =
|
||
static_cast<float>(m_constraint.maxScrollRows) * kRowHeight * m_scale;
|
||
viewportLimit = (viewportLimit > 0.0f) ? std::min(viewportLimit, rows) : rows;
|
||
}
|
||
|
||
if (viewportLimit >= kRowHeight * m_scale && scrollRegionHeight > viewportLimit + 0.5f)
|
||
{
|
||
const float viewportHeight = std::min(scrollRegionHeight, viewportLimit);
|
||
height = kPadV * 2.0f * m_scale + headHeight + viewportHeight + tailHeight;
|
||
m_scrollEnabled = true;
|
||
m_scrollFirst = scrollFirst;
|
||
m_pinnedIndex = pinnedFrom;
|
||
m_scrollRegionHeight = scrollRegionHeight;
|
||
m_scrollOffset = std::max(
|
||
0.0f, std::min(m_scrollOffset, std::max(0.0f, scrollRegionHeight - viewportHeight)));
|
||
}
|
||
else
|
||
{
|
||
m_scrollOffset = 0.0f;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
m_scrollOffset = 0.0f;
|
||
}
|
||
|
||
// 带说明文字的 EDIT 项共用**同一个输入框宽度**:取其中最大的自然宽度,
|
||
// 再按“给最宽的说明文字留位 / 不超过内容区 3/4”夹取。
|
||
// ⚠️ 若每项各自按自己的文本长度算,同一面板里就会出现一个宽一个窄
|
||
// (时间轴行的文本更长、还带单位后缀,于是它的输入框比 Y 轴行宽一截)。
|
||
float labeledFieldWidth = 0.0f;
|
||
float widestLabel = 0.0f;
|
||
for (const MenuItem& item : m_items)
|
||
{
|
||
if (item.kind != MenuItemKind::EDIT || item.label.empty())
|
||
{
|
||
continue;
|
||
}
|
||
labeledFieldWidth = std::max(labeledFieldWidth, NaturalEditFieldWidth(item));
|
||
widestLabel = std::max(widestLabel,
|
||
UiMeasureTextWidth(m_fonts->uiSmall, item.label) +
|
||
kEditFieldGap * m_scale);
|
||
}
|
||
if (labeledFieldWidth > 0.0f)
|
||
{
|
||
const float rowContent =
|
||
std::max(40.0f * m_scale, (width - 2.0f * (kPadH + kItemPad) * m_scale));
|
||
const float limit = std::max(40.0f * m_scale,
|
||
std::min(rowContent * 0.75f, rowContent - widestLabel));
|
||
labeledFieldWidth = std::min(labeledFieldWidth, limit);
|
||
}
|
||
m_editFieldWidth = labeledFieldWidth;
|
||
|
||
float left = static_cast<float>(x);
|
||
// `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。
|
||
float top = expandUp ? (static_cast<float>(y) - height) : static_cast<float>(y);
|
||
|
||
// 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。
|
||
if (left + width > bounds.X + bounds.Width)
|
||
{
|
||
left = static_cast<float>(x) - width;
|
||
}
|
||
if (!expandUp && top + height > bounds.Y + bounds.Height)
|
||
{
|
||
top = static_cast<float>(y) - height;
|
||
}
|
||
left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f));
|
||
top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f));
|
||
|
||
m_rect = gd::RectF(left, top, width, height);
|
||
|
||
// 条目排布:头部固定区从菜单顶往下排;滚动区条目整体上移 `m_scrollOffset`;
|
||
// 尾部固定区从菜单底部往上对齐。
|
||
const float bodyTop = top + kPadV * m_scale;
|
||
const float tailTop = top + height - kPadV * m_scale - tailHeight;
|
||
const float scrollBarReserve = m_scrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f;
|
||
m_scrollViewport =
|
||
m_scrollEnabled
|
||
? gd::RectF(left + kPadH * m_scale, bodyTop + headHeight,
|
||
std::max(0.0f, width - kPadH * 2.0f * m_scale),
|
||
std::max(0.0f, height - kPadV * 2.0f * m_scale - headHeight - tailHeight))
|
||
: gd::RectF();
|
||
|
||
float cursorY = bodyTop;
|
||
float headCursorY = bodyTop;
|
||
float scrollCursorY = bodyTop + headHeight - m_scrollOffset;
|
||
float tailCursorY = (m_scrollEnabled) ? tailTop : bodyTop;
|
||
for (std::size_t index = 0; index < m_items.size(); ++index)
|
||
{
|
||
const MenuItem& item = m_items[index];
|
||
const float itemHeight = heights[index];
|
||
|
||
float rectY = cursorY;
|
||
float rectWidth = std::max(0.0f, width - kPadH * 2.0f * m_scale);
|
||
if (m_scrollEnabled)
|
||
{
|
||
const int itemIndex = static_cast<int>(index);
|
||
if (itemIndex < m_scrollFirst)
|
||
{
|
||
rectY = headCursorY;
|
||
headCursorY += itemHeight;
|
||
}
|
||
else if (itemIndex < m_pinnedIndex)
|
||
{
|
||
rectY = scrollCursorY;
|
||
scrollCursorY += itemHeight;
|
||
// 滚动区条目让出右侧滚动条的位置(对勾/文本不会压在滑块上)。
|
||
rectWidth = std::max(0.0f, rectWidth - scrollBarReserve);
|
||
}
|
||
else
|
||
{
|
||
rectY = tailCursorY;
|
||
tailCursorY += itemHeight;
|
||
}
|
||
}
|
||
m_itemRects[index] =
|
||
gd::RectF(left + kPadH * m_scale, rectY, rectWidth, itemHeight);
|
||
|
||
if (item.kind == MenuItemKind::SWATCHES)
|
||
{
|
||
const int count = std::max(0, std::min(item.swatchCount, 16));
|
||
std::vector<gd::RectF>& rects = m_swatchRects[index];
|
||
rects.reserve(static_cast<std::size_t>(count));
|
||
const float swatchY = rectY + (itemHeight - kSwatchSize * m_scale) * 0.5f;
|
||
const float startX = left + (kPadH + kItemPad) * m_scale;
|
||
for (int swatch = 0; swatch < count; ++swatch)
|
||
{
|
||
const float swatchX =
|
||
startX + static_cast<float>(swatch) * (kSwatchSize + kSwatchGap) * m_scale;
|
||
rects.push_back(gd::RectF(swatchX, swatchY, kSwatchSize * m_scale, kSwatchSize * m_scale));
|
||
}
|
||
}
|
||
|
||
cursorY += itemHeight;
|
||
}
|
||
|
||
// 滚动条轨道:贴在可滚动条目区的右内侧(只在条目区旁边,不会盖住固定区)。
|
||
if (m_scrollEnabled)
|
||
{
|
||
const float thickness = kScrollBarWidth * m_scale;
|
||
m_scrollBarRect = gd::RectF(left + width - kPadH * m_scale - thickness,
|
||
m_scrollViewport.Y, thickness, m_scrollViewport.Height);
|
||
}
|
||
}
|
||
|
||
bool PopupMenu::ScrollRegion(int& first, int& last) const
|
||
{
|
||
if (!m_scrollEnabled || m_pinnedIndex < 0)
|
||
{
|
||
first = 0;
|
||
last = static_cast<int>(m_items.size());
|
||
return false;
|
||
}
|
||
first = m_scrollFirst;
|
||
last = m_pinnedIndex;
|
||
return true;
|
||
}
|
||
|
||
gd::RectF PopupMenu::ScrollBarThumbRect() const
|
||
{
|
||
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f || m_scrollRegionHeight <= 0.0f)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
const float viewport = m_scrollViewport.Height;
|
||
if (viewport <= 0.0f)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
const float ratio = std::min(1.0f, viewport / m_scrollRegionHeight);
|
||
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, m_scrollBarRect.Height * ratio);
|
||
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
|
||
const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f;
|
||
const float thumbY =
|
||
m_scrollBarRect.Y +
|
||
std::max(0.0f, std::min(1.0f, progress)) * std::max(0.0f, m_scrollBarRect.Height - thumbHeight);
|
||
return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight);
|
||
}
|
||
|
||
float PopupMenu::ScrollOffsetForThumbTop(float thumbTop) const
|
||
{
|
||
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f)
|
||
{
|
||
return m_scrollOffset;
|
||
}
|
||
const float viewport = m_scrollViewport.Height;
|
||
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
|
||
if (maxScroll <= 0.0f)
|
||
{
|
||
return 0.0f;
|
||
}
|
||
const float track = m_scrollBarRect.Height;
|
||
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, track * std::min(1.0f, viewport / std::max(1.0f, m_scrollRegionHeight)));
|
||
const float travel = std::max(0.0f, track - thumbHeight);
|
||
if (travel <= 0.0f)
|
||
{
|
||
return 0.0f;
|
||
}
|
||
const float relative = thumbTop - m_scrollBarRect.Y;
|
||
const float progress = std::max(0.0f, std::min(1.0f, relative / travel));
|
||
return progress * maxScroll;
|
||
}
|
||
|
||
bool PopupMenu::EnsureItemVisible(int index)
|
||
{
|
||
if (!m_scrollEnabled || index < m_scrollFirst || index >= m_pinnedIndex ||
|
||
m_scrollViewport.Height <= 0.0f)
|
||
{
|
||
return false;
|
||
}
|
||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||
const float relative = rect.Y - m_scrollViewport.Y;
|
||
float delta = 0.0f;
|
||
if (relative < 0.0f)
|
||
{
|
||
delta = relative;
|
||
}
|
||
else if (relative + rect.Height > m_scrollViewport.Height)
|
||
{
|
||
delta = relative + rect.Height - m_scrollViewport.Height;
|
||
}
|
||
if (std::fabs(delta) < 0.5f)
|
||
{
|
||
return false;
|
||
}
|
||
m_scrollOffset += delta;
|
||
// 重新排布(内部会重新夹取 `m_scrollOffset`)。
|
||
RebuildLayout();
|
||
return true;
|
||
}
|
||
|
||
bool PopupMenu::OnMouseWheel(int delta)
|
||
{
|
||
if (!m_open || !m_scrollEnabled || delta == 0)
|
||
{
|
||
return false;
|
||
}
|
||
const float previous = m_scrollOffset;
|
||
// 向上滚(`delta > 0`)= 看更靠前的条目 ⇒ 偏移减小。
|
||
m_scrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
|
||
// 重新排布:条目矩形依赖滚动偏移,同时会夹取到合法范围。
|
||
RebuildLayout();
|
||
return std::fabs(m_scrollOffset - previous) > 0.01f;
|
||
}
|
||
|
||
float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const
|
||
{
|
||
const std::wstring& widest =
|
||
(item.editValue.size() > item.value.size()) ? item.editValue : item.value;
|
||
const float natural = std::max(kEditFieldWidth * m_scale,
|
||
UiMeasureTextWidth(m_fonts->uiSmall, widest) + 20.0f * m_scale);
|
||
const float withSuffix = natural + EditSuffixWidth(item);
|
||
// 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。
|
||
return item.label.empty() ? std::max(withSuffix, kRowFieldWidth * m_scale) : withSuffix;
|
||
}
|
||
|
||
void PopupMenu::CloseSubmenu()
|
||
{
|
||
m_subOpen = false;
|
||
m_subParentIndex = -1;
|
||
m_subRect = gd::RectF();
|
||
m_subItems.clear();
|
||
m_subItemRects.clear();
|
||
m_subHoverIndex = -1;
|
||
m_subPressedIndex = -1;
|
||
}
|
||
|
||
void PopupMenu::BuildSubLayout(int parentIndex)
|
||
{
|
||
CloseSubmenu();
|
||
if (parentIndex < 0 || parentIndex >= static_cast<int>(m_items.size()))
|
||
{
|
||
return;
|
||
}
|
||
const MenuItem& parent = m_items[static_cast<std::size_t>(parentIndex)];
|
||
if (parent.kind != MenuItemKind::SUBMENU || parent.submenu.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_subItems = parent.submenu;
|
||
|
||
// 与主菜单一致:宽度取条目中的最大者并夹在上下限之间。
|
||
float contentWidth = kMinWidth * m_scale;
|
||
float totalHeight = kPadV * 2.0f * m_scale;
|
||
for (const MenuItem& item : m_subItems)
|
||
{
|
||
totalHeight += ItemHeight(item);
|
||
if (item.kind == MenuItemKind::SWATCHES)
|
||
{
|
||
const int count = std::max(0, std::min(item.swatchCount, 16));
|
||
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
|
||
static_cast<float>(count) * kSwatchSize * m_scale +
|
||
static_cast<float>(std::max(0, count - 1)) * kSwatchGap * m_scale;
|
||
contentWidth = std::max(contentWidth, width);
|
||
}
|
||
else if (item.kind == MenuItemKind::HEADER)
|
||
{
|
||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||
kMarkerGap * m_scale +
|
||
UiMeasureTextWidth(m_fonts->uiSmallBold, item.label);
|
||
contentWidth = std::max(contentWidth, width);
|
||
}
|
||
else if (item.kind == MenuItemKind::EDIT)
|
||
{
|
||
const float fieldWidth = NaturalEditFieldWidth(item);
|
||
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
|
||
UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale +
|
||
fieldWidth;
|
||
contentWidth = std::max(contentWidth, width);
|
||
}
|
||
else if (item.kind == MenuItemKind::NORMAL || item.kind == MenuItemKind::SUBMENU)
|
||
{
|
||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||
kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label);
|
||
contentWidth = std::max(contentWidth, width);
|
||
}
|
||
}
|
||
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
|
||
|
||
const float width = contentWidth;
|
||
const float height = totalHeight;
|
||
const gd::RectF& parentRect = m_itemRects[static_cast<std::size_t>(parentIndex)];
|
||
const gd::RectF& bounds = m_buildBounds;
|
||
|
||
// 默认贴主菜单右缘并向上与父项顶端对齐;右侧空间不足时翻到左侧。
|
||
float left = m_rect.X + m_rect.Width - kSubOverlap * m_scale;
|
||
if (left + width > bounds.X + bounds.Width - 2.0f)
|
||
{
|
||
left = m_rect.X - width + kSubOverlap * m_scale;
|
||
}
|
||
left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f));
|
||
|
||
float top = parentRect.Y - kPadV * m_scale;
|
||
top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f));
|
||
|
||
m_subRect = gd::RectF(left, top, width, height);
|
||
m_subItemRects.assign(m_subItems.size(), gd::RectF());
|
||
|
||
float cursorY = top + kPadV * m_scale;
|
||
for (std::size_t index = 0; index < m_subItems.size(); ++index)
|
||
{
|
||
const float itemHeight = ItemHeight(m_subItems[index]);
|
||
m_subItemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY,
|
||
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight);
|
||
cursorY += itemHeight;
|
||
}
|
||
|
||
m_subParentIndex = parentIndex;
|
||
m_subOpen = true;
|
||
m_subHoverIndex = -1;
|
||
m_subPressedIndex = -1;
|
||
}
|
||
|
||
int PopupMenu::SubItemAtPoint(int x, int y) const
|
||
{
|
||
if (!m_subOpen)
|
||
{
|
||
return -1;
|
||
}
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
for (std::size_t index = 0; index < m_subItems.size(); ++index)
|
||
{
|
||
const MenuItem& item = m_subItems[index];
|
||
if (item.kind == MenuItemKind::SEPARATOR || item.kind == MenuItemKind::HEADER)
|
||
{
|
||
continue;
|
||
}
|
||
if (!item.enabled)
|
||
{
|
||
continue;
|
||
}
|
||
if (UiPointInRect(m_subItemRects[index], fx, fy))
|
||
{
|
||
return static_cast<int>(index);
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
float PopupMenu::EditSuffixWidth(const MenuItem& item) const
|
||
{
|
||
if (item.kind != MenuItemKind::EDIT || item.editSuffix.empty())
|
||
{
|
||
return 0.0f;
|
||
}
|
||
return UiMeasureTextWidth(m_fonts->uiSmall, item.editSuffix) + 8.0f * m_scale;
|
||
}
|
||
|
||
gd::RectF PopupMenu::EditFieldRect(int index) const
|
||
{
|
||
if (index < 0 || index >= static_cast<int>(m_items.size()))
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||
if (item.kind != MenuItemKind::EDIT)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
|
||
// 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度,
|
||
// 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。
|
||
// ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限,
|
||
// 让输入框独占整行。
|
||
// ⚠️ 带说明文字的项统一使用排布阶段算好的 `m_editFieldWidth`:
|
||
// 同一面板里所有这类输入框必须一样宽、右边界对齐。
|
||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||
const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale);
|
||
const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale;
|
||
float width = NaturalEditFieldWidth(item);
|
||
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
|
||
if (item.label.empty())
|
||
{
|
||
// 整行输入框(无说明文字):占满内容区,视觉上就是一个独立的一行输入框。
|
||
width = contentWidth;
|
||
}
|
||
else
|
||
{
|
||
if (m_editFieldWidth > 0.0f)
|
||
{
|
||
width = m_editFieldWidth;
|
||
}
|
||
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
|
||
width = std::min(width, contentWidth * 0.75f);
|
||
}
|
||
|
||
const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale;
|
||
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width,
|
||
rect.Y + (rect.Height - height) * 0.5f, width, height);
|
||
}
|
||
|
||
gd::RectF PopupMenu::HeaderIconRect(int index) const
|
||
{
|
||
if (index < 0 || index >= static_cast<int>(m_items.size()))
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||
if (item.kind != MenuItemKind::HEADER_ACTION)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||
const float size = std::min(kHeaderIconSize * m_scale, std::max(6.0f, rect.Height - 4.0f * m_scale));
|
||
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - size,
|
||
rect.Y + (rect.Height - size) * 0.5f, size, size);
|
||
}
|
||
|
||
bool PopupMenu::IsOnEditField(int x, int y) const
|
||
{
|
||
if (m_editIndex < 0)
|
||
{ return false;
|
||
}
|
||
const gd::RectF rect = EditFieldRect(m_editIndex);
|
||
return UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y));
|
||
}
|
||
|
||
bool PopupMenu::BeginItemEdit(int index)
|
||
{
|
||
if (!m_open || index < 0 || index >= static_cast<int>(m_items.size()))
|
||
{
|
||
return false;
|
||
}
|
||
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||
if (item.kind != MenuItemKind::EDIT || !item.enabled)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 输入框初值取「编辑初值」(未设置时用显示值),整段选中,便于直接键入新数值。
|
||
m_edit.Begin(item.editValue.empty() ? item.value : item.editValue, true, item.filter, 0u);
|
||
m_editIndex = index;
|
||
m_hoverIndex = index;
|
||
return true;
|
||
}
|
||
|
||
bool PopupMenu::EndItemEdit(bool commit, MenuResult& result)
|
||
{
|
||
if (m_editIndex < 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const int index = m_editIndex;
|
||
const std::wstring text = m_edit.Text();
|
||
m_editIndex = -1;
|
||
m_edit.End();
|
||
|
||
if (!commit || index >= static_cast<int>(m_items.size()))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||
// 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值);
|
||
// 键入文本同时记为**编辑初值**(`value` 与 `editValue` 不一致的行也能正确回显上次输入)。
|
||
item.value = text;
|
||
item.editValue = text;
|
||
|
||
result.valid = true;
|
||
result.scope = m_scope;
|
||
result.id = item.id;
|
||
result.editCommit = true;
|
||
result.editText = text;
|
||
return true;
|
||
}
|
||
|
||
void PopupMenu::RebuildLayout()
|
||
{
|
||
if (!m_open)
|
||
{
|
||
return;
|
||
}
|
||
const int subParent = m_subParentIndex;
|
||
BuildLayout(m_buildBounds, m_buildX, m_buildY, m_buildExpandUp);
|
||
// 主菜单重新排布后子菜单位置会失效:若之前是展开的则按新几何重建。
|
||
if (subParent >= 0)
|
||
{
|
||
BuildSubLayout(subParent);
|
||
}
|
||
}
|
||
|
||
bool PopupMenu::UpdateItemValue(int id, const std::wstring& value, const std::wstring& editValue)
|
||
{
|
||
for (MenuItem& item : m_items)
|
||
{
|
||
if (item.id == id && item.kind == MenuItemKind::EDIT)
|
||
{
|
||
const bool valueSame = (item.value == value);
|
||
const bool editSame = editValue.empty() || (item.editValue == editValue);
|
||
if (valueSame && editSame)
|
||
{
|
||
return true;
|
||
}
|
||
item.value = value;
|
||
if (!editValue.empty())
|
||
{
|
||
item.editValue = editValue;
|
||
}
|
||
RebuildLayout();
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool PopupMenu::UpdateItemLabel(int id, const std::wstring& label, bool checked, bool enabled)
|
||
{
|
||
for (MenuItem& item : m_items)
|
||
{
|
||
if (item.id == id && item.kind == MenuItemKind::NORMAL)
|
||
{
|
||
if (item.label == label && item.checked == checked && item.enabled == enabled)
|
||
{
|
||
return true;
|
||
}
|
||
item.label = label;
|
||
item.checked = checked;
|
||
item.enabled = enabled;
|
||
RebuildLayout();
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool PopupMenu::UpdateSubmenuItemLabel(int id, const std::wstring& label, bool checked,
|
||
bool enabled)
|
||
{
|
||
for (std::size_t parentIndex = 0; parentIndex < m_items.size(); ++parentIndex)
|
||
{
|
||
MenuItem& parent = m_items[parentIndex];
|
||
if (parent.kind != MenuItemKind::SUBMENU)
|
||
{
|
||
continue;
|
||
}
|
||
for (MenuItem& option : parent.submenu)
|
||
{
|
||
if (option.id != id || option.kind != MenuItemKind::NORMAL)
|
||
{
|
||
continue;
|
||
}
|
||
if (option.label == label && option.checked == checked && option.enabled == enabled)
|
||
{
|
||
return true;
|
||
}
|
||
option.label = label;
|
||
option.checked = checked;
|
||
option.enabled = enabled;
|
||
// 子菜单是 `parent.submenu` 的**快照**(`m_subItems`),必须重建才生效。
|
||
if (m_subOpen && m_subParentIndex == static_cast<int>(parentIndex))
|
||
{
|
||
BuildSubLayout(static_cast<int>(parentIndex));
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
} // namespace jdi
|