Files
Just-DataInspector/Components/Shared/Src/PopupMenu.cpp
T
DeepSeek V4.1 Flash 71195c4c48 新增主题配色(4 套)+ 信号右键菜单精简与「克隆该信号」+ 布局边界层级根治 + 单点信号吸附修复
## 为什么

用户 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)。
2026-10-29 20:38:55 +08:00

758 lines
19 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>
#include "PopupMenuInternal.hpp"
#include "UiControls.hpp"
#include "UiBox.hpp"
#include "UiLayout.hpp"
namespace jdi
{
PopupMenu::PopupMenu() = default;
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)];
// 带复选框的子菜单项(如「主题配色」的四个主题)选择后**保持菜单打开**,
// 便于连续预览;勾选态由宿主通过 `UpdateSubmenuItemLabel` 回写。
if (!item.keepOpen)
{
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::BuildBoxes(UiBoxTable& boxes) const
{
if (!m_open)
{
return;
}
// 层级 0:菜单浮层整体。
boxes.Add(m_rect, 0);
// 菜单是不透明浮层 ⇒ 登记为遮挡矩形:下层组件(面板行 / 网格)的边框线不再画到它上面。
boxes.AddOccluder(m_rect);
// 层级 1:每个条目(含标题 / 分隔线 / 色块行,它们的矩形都已算好)。
// ⚠️ **必须裁剪到可视区**:`m_itemRects` 里包含**滚出视口**的条目(它们只是不绘制),
// 不裁剪会把调试框画到菜单外面 —— 看起来就像"滚动时条目仍然在绘制",
// 也让菜单的边界看起来"超出了自身"(用户 2026-10-29 报告)。
boxes.SetClip(m_scrollEnabled ? m_scrollViewport : m_rect);
for (const gd::RectF& item : m_itemRects)
{
boxes.Add(item, 1);
}
boxes.SetClip(gd::RectF());
// 层级 1/2:可滚动菜单的可视区、轨道与滑块。
boxes.Add(m_scrollViewport, 1);
boxes.Add(m_scrollBarRect, 2);
boxes.Add(ScrollBarThumbRect(), 2);
// 层级 0/1:二级子菜单(**另起一个绘制层**:它压在菜单项之上)。
if (UiRectHasArea(m_subRect))
{
boxes.PushLayer();
boxes.Add(m_subRect, 0);
boxes.AddOccluder(m_subRect);
// 同理:子菜单条目也必须裁到子菜单自身,否则滚动时同样会"溢出"。
boxes.SetClip(m_subRect);
for (const gd::RectF& item : m_subItemRects)
{
boxes.Add(item, 1);
}
boxes.SetClip(gd::RectF());
}
}
} // namespace jdi