Files
Just-DataInspector/Utilities/Inc/PopupMenu.hpp
T
DeepSeek V4.1 Flash 940b8e67e6 面板/拾色器布局微调 + 修复“拖拽光标飞到视界边缘”(含根因与防复发)
一、样式区「系数/偏移」二级面板:两个偏移输入框等宽对齐
- 带说明文字的 EDIT 行原来各自按自己的文本长度 + 单位后缀算宽度,于是「时间轴」
  (文本更长、还带 `s` 后缀)的输入框比「Y轴」宽一截。现在同一次排布里所有
  带说明文字的输入框共用**同一个宽度**(取其中最大自然宽度,再按“给最宽的说明文字
  留位 / 不超过内容区 3/4”夹取),因此「Y轴」与「时间轴」严格等宽、右边界对齐。
- 断言:两个输入框 126.0 / 126.0,右边界 277.8 / 277.8。

二、自定义颜色浮层(拾色器)的绘制与排版
- **圆角不再漏像素**:色域方块的位图与色相条的渐变填充都**裁剪进同一个圆角路径**
  (新增公开的 `UiBuildRoundRectPath`,与 `UiFillRoundRect` / `UiDrawRoundRect`
  逐点同源),色相条的圆角与色域方块完全一致。以前填充是直角矩形,会在圆角描边之外
  露出四个尖角(色域左下/右下本身就是接近黑色的极值,看起来就是"角落里多出黑色像素点")。
- 色域位图改用 `WrapModeTileFlipXY` 贴图:插值不再采样到边界之外,边缘也不会被压暗。
- 色相条改用**一条带 7 个色标的线性渐变**画满(颜色与原来 6 段拼色一致),
  顺带去掉段与段之间的接缝横线。
- RGB 三行:轨道**两侧间隙相等**(左:字母 ↔ 轨道;右:轨道 ↔ 数值区,共用同一常量),
  数值区固定 **3 个字符宽**(与数字本身无关),三行轨道等长、同 x,R/G/B 字母与数值
  都**右对齐**,因此三个字母、三列数字纵向对齐。
- 色号:固定 **6+1 个字符宽**,**右边界与三行数值的右边界对齐**(像素级验证:两者
  最后一个字符同列);预览色块的长宽不变、左边界与轨道左边界对齐。
- 数值/色号正好 3 / 7 个字符宽,编辑态需要把文本区向外扩出输入框内边距,否则最后
  一个字符会被输入框内边距裁掉(新增 `PaddedEditRect`,绘制、命中与插入符定位共用)。

三、修复“用选区放大把视界放大到某个临界点后,拖拽光标就飞到视界边缘”
两个独立原因,都属于“不变量只在某一个调用点维护”:
1. **吸附到采样点**没有边界检查:只要采样点间距 ≥6px 就吸附,而视界一旦比一个采样间隔还窄
   (选区放大很容易做到),“最近的采样点”就落在视界之外,于是每一次鼠标移动都把光标
   吸到视界外、再被夹回边缘 → 表现为光标“飞到视界边缘后跟不动鼠标”。现在吸附要求
   视界内至少放得下 2 个采样点,且只接受**可见区间内**的采样点。
2. **选区放大没有最小跨度下限**:滚轮缩放有 `数据跨度 × 1e-9` 的下限,选区放大没有,
   可以一直放大到视界宽度只有几个 double ULP。此时“像素 → 时间”的映射退化
   (相邻像素映射到同一个 double),鼠标一动光标就跨几百像素。现在新增
   `MinimumTimeSpan`(数据跨度 × 1e-9 与 `ULP(时间) × 画布宽度` 取大者)与
   **视界的唯一写入口 `SetTimeView`**(拒绝非有限值、纠正左右颠倒、抬到下限之上、
   再按数据范围收敛),滚轮缩放 / 选区放大 / 平移 / 适应视图 / 时间轴适应网格全部走它。
- 另新增**光标位置的唯一写入口 `PlaceCursorTime`**(拖拽光标必须经由此处):
  拒绝非有限值 → 吸附(带上限与可见区间检查)→ 夹回可见区间。`XToTime` 也补上了
  跨度退化时的兜底。
- ⚠️ 为什么会反复出现:这两个不变量(“视界必须仍然可表示/可用”“光标必须落在可见区间内、
  且对应鼠标位置”)过去是**在每个调用点各自维护**的。后来加入的路径(选区放大、采样点吸附、
  派生信号/偏移等新算子)各自绕过了其中一个环节,而故障只有在放大到极限时才可见,
  于是每加一个新功能就可能复发一次。现在视界与光标各只有一个守门人,新功能必须经过它们。

四、离屏验证(窗口隐藏、不碰屏幕/鼠标/键盘)
- 新增 [AQ]:视界比采样间隔还窄时拖光标必须跟随鼠标(修复前偏差 630px、直接贴边);
  视界内采样点仍精确吸附;连续橡胶带放大不会再突破可分辨下限(停在 1e-9 数据跨度),
  且在极限缩放下拖拽仍精确跟随(修复前 490px 偏差)。
- 新增 [AR]:色域方块与色相条四角之外不再有填充像素(角像素从填充色变为面板底色)、
  两侧间隙相等、数值列 3 字符、色号 7 字符且与数值右边界同列(像素级)、预览色块左边界
  与轨道左边界对齐、三条轨道等长同 x。
- [AP] 增加两个偏移输入框等宽/右对齐断言。
- ⚠️ 修正 harness 的一个**假通过**:`SetAllVisible(false)` 会清掉标签页的“视图已初始化”标记,
  首次渲染的 `FitAll()` 会把测试设置的深缩放覆盖掉([Z]/[AI]/[AQ] 的“深缩放”其实一直在
  整段数据上跑,所以这类回归能溜过去)。现在相关用例显式置位该标记,[Z]/[AI] 真正进入
  深缩放;并补上用例间残留弹出菜单的隔离(面板停在窗口下半部会向上翻转盖住按钮,
  吞掉下一个用例的首次点击)。
- 合计 669/669 通过(连续 4 次);`make release` 零警告(Target.exe 正在运行,链接待其关闭,
  已用临时输出名验证链接自身成功)。
2026-10-13 20:20:38 +08:00

436 lines
15 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.
#ifndef __POPUP_MENU_HPP__
#define __POPUP_MENU_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <cstdint>
#include <string>
#include <vector>
#include "PaintUtil.hpp"
#include "TextEdit.hpp"
#include "Theme.hpp"
namespace jdi
{
/**
* @brief 弹出菜单的打开来源,用于把选择结果派发回正确的组件。
*/
enum class MenuScope : int
{
NONE = 0, /**< 无来源。 */
SIGNAL_PANEL = 1, /**< 信号面板。 */
TAB_BAR = 2, /**< 标签栏。 */
PLOT_VIEW = 3 /**< 波形绘图区。 */
};
/**
* @brief 弹出菜单项的显示类型。
*/
enum class MenuItemKind : int
{
NORMAL = 0, /**< 普通可点击项。 */
SEPARATOR = 1, /**< 分隔线。 */
HEADER = 2, /**< 分组标题(不可点击)。 */
SWATCHES = 3, /**< 颜色色块行。 */
EDIT = 4, /**< 带内联输入框的项:在菜单内部直接编辑数值(菜单不关闭)。 */
SUBMENU = 5, /**< 带二级子菜单的项:鼠标悬停时向右展开。 */
HEADER_ACTION = 6 /**< 分组标题 + **右侧图标按钮**(如「还原」),图标可点击。 */
};
/**
* @brief 单个弹出菜单项。
*/
struct MenuItem
{
MenuItemKind kind = MenuItemKind::NORMAL; /**< 项目类型。 */
std::wstring label; /**< 显示文本;EDIT 项为输入框左侧的说明文字。 */
int id = 0; /**< 命令标识。 */
bool enabled = true; /**< 是否可用。 */
bool checked = false; /**< 是否处于选中(单选)状态。 */
int swatchCount = 0; /**< 色块数量(仅色块行有效)。 */
std::uint32_t swatches[16] = {}; /**< 色块颜色。 */
int checkedSwatch = -1; /**< 被选中的色块下标。 */
std::wstring value; /**< EDIT 项的当前数值文本(非编辑态显示)。 */
/**
* EDIT 项**进入编辑时的初始文本**;为空则使用 `value`。
*
* 用于「非编辑态的显示文本」与「编辑态的数值」不一致的场合,例如时间轴偏移:
* 平时显示 `Δt:5s 60ms`,进入编辑后应显示以秒为单位的数值 `5.06`。
*/
std::wstring editValue;
/**
* EDIT 项**编辑态**在输入框最右侧提示的单位文本(如 `s`);为空则不提示。
*
* 非编辑态不显示(那时显示的是 `value` 的完整文案)。
*/
std::wstring editSuffix;
EditFilter filter = EditFilter::ANY; /**< EDIT 项的输入过滤规则。 */
std::vector<MenuItem> submenu; /**< 二级子菜单项(kind == SUBMENU 时有效)。 */
/**
* 普通项被点击后是否**保持菜单打开**。
*
* 用于「带复选框的切换项」(如镜像的水平/垂直):可以连续勾选多项,
* 勾选状态通过 `UpdateItemLabel` 回写。默认 false(点击即关):与既有行为一致。
*/
bool keepOpen = false;
};
/**
* @brief 菜单选择结果。
*/
struct MenuResult
{
bool valid = false; /**< 是否产生了有效选择。 */
MenuScope scope = MenuScope::NONE; /**< 来源组件。 */
int id = 0; /**< 被选中项的命令标识。 */
int swatchIndex = -1; /**< 被选中的色块下标(非色块行为 -1)。 */
gd::RectF itemRect; /**< 被点击条目的矩形(客户区坐标),供就近弹出输入框。 */
bool editCommit = false; /**< 该结果来自菜单内联输入框的提交(菜单保持打开)。 */
std::wstring editText; /**< 提交的输入文本(editCommit 为 true 时有效)。 */
};
/**
* @brief 与界面主题一致的自绘弹出菜单。
*
* 菜单不创建独立窗口,而是作为宿主窗口绘制流程的最上层内容直接绘制到后备缓冲区,
* 因此外观与主界面完全统一,不会出现系统默认菜单样式。
*/
class PopupMenu
{
public:
PopupMenu();
/**
* @brief 绑定字体与主题资源。
* @param [IN] fonts 字体集合,需在菜单生命周期内保持有效。
* @param [IN] theme 主题配色,需在菜单生命周期内保持有效。
*/
void Setup(const UiFonts* fonts, const Theme* theme);
/**
* @brief 设置 DPI 缩放比例。
* @param [IN] scale 缩放比例(1.0 对应 96 DPI)。
*/
void SetScale(float scale);
/**
* @brief 在指定位置打开菜单。
* @param [IN] scope 来源组件标识。
* @param [IN] bounds 宿主客户区矩形,用于把菜单限制在窗口内。
* @param [IN] x 锚点横坐标(客户区坐标)。
* @param [IN] y 锚点纵坐标(客户区坐标)。
* @param [IN] items 菜单项集合。
*/
void Open(MenuScope scope, const gd::RectF& bounds, int x, int y, const std::vector<MenuItem>& items);
/**
* @brief 关闭菜单。
*/
void Close();
/**
* @brief 判断菜单是否处于打开状态。
* @return 打开返回 true。
*/
bool IsOpen() const { return m_open; }
/**
* @brief 获取当前菜单的来源组件标识。
* @return 来源标识。
*/
MenuScope Scope() const { return m_scope; }
/**
* @brief 绘制菜单(应在宿主绘制的最后一步调用)。
* @param [IN,OUT] g 绘图上下文。
*/
void Paint(gd::Graphics& g);
/**
* @brief 处理鼠标移动。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnMouseMove(int x, int y);
/**
* @brief 处理鼠标左键按下。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [OUT] result 在菜单外按下时返回被取消的选择结果。
* @return 事件已被菜单消费返回 true。
*/
bool OnLButtonDown(int x, int y, MenuResult& result);
/**
* @brief 处理鼠标左键抬起。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [OUT] result 选择结果。
* @return 事件已被菜单消费返回 true。
*/
bool OnLButtonUp(int x, int y, MenuResult& result);
/**
* @brief 处理键盘按键。
* @param [IN] key 虚拟键码。
* @param [OUT] result 选择结果。
* @return 事件已被菜单消费返回 true。
*/
bool OnKeyDown(WPARAM key, MenuResult& result);
/**
* @brief 判断坐标是否落在菜单内部。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 落在菜单内返回 true。
*/
bool ContainsPoint(int x, int y) const;
/**
* @brief 设置剪贴板所有者窗口句柄(供内联输入框复制/粘贴)。
* @param [IN] host 宿主窗口句柄,允许为空。
*/
void SetHostWindow(HWND host) { m_host = host; }
/**
* @brief 判断是否有菜单项正处于内联编辑状态。
* @return 处于编辑状态返回 true。
*/
bool IsEditing() const { return m_editIndex >= 0; }
/**
* @brief 判断坐标是否落在当前正在编辑的输入框内。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 落在输入框内返回 true。
*/
bool IsOnEditField(int x, int y) const;
/**
* @brief 编辑态下按下左键:定位插入符并开始拖拽框选。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中输入框返回 true。
*/
bool OnEditMouseDown(int x, int y);
/**
* @brief 编辑态下拖拽鼠标:扩展选区。
* @param [IN] x 客户区横坐标。
* @return 选区发生变化返回 true。
*/
bool OnEditMouseMove(int x);
/**
* @brief 编辑态下松开左键:结束拖拽框选。
* @return 之前处于拖拽中返回 true。
*/
bool OnEditMouseUp();
/**
* @brief 编辑态下处理字符输入。
* @param [IN] character 字符。
* @param [OUT] result 提交时返回 editCommit 结果。
* @return 需要重绘返回 true。
*/
bool OnChar(wchar_t character, MenuResult& result);
/**
* @brief 更新指定命令项显示的值文本(菜单保持打开,立即重新排布)。
* @param [IN] id 命令标识。
* @param [IN] value 新的值文本(非编辑态显示)。
* @param [IN] editValue 新的**编辑初值**(为空时保持原值)。
* ⚠️ 非编辑态文本与编辑态文本不一致的行(如时间轴偏移:`Δt:1h 1min 2s` ↔ `3662`)
* 必须一并更新,否则下次进入编辑会拿到陈旧的初值。
* @return 找到对应项并更新返回 true。
*/
bool UpdateItemValue(int id, const std::wstring& value,
const std::wstring& editValue = std::wstring());
/**
* @brief 更新指定普通项的文字与可用/选中状态(用于实时反映状态)。
* @param [IN] id 命令标识。
* @param [IN] label 新的文字。
* @param [IN] checked 新的选中状态。
* @param [IN] enabled 新的可用状态。
* @return 找到对应项并更新返回 true。
*/
bool UpdateItemLabel(int id, const std::wstring& label, bool checked = false, bool enabled = true);
private:
/**
* @brief 计算单个菜单项的纵向高度。
* @param [IN] item 菜单项。
* @return 高度(像素)。
*/
float ItemHeight(const MenuItem& item) const;
/**
* @brief 依据当前项目集合计算整体矩形与各项矩形。
* @param [IN] bounds 宿主客户区矩形。
* @param [IN] x 锚点横坐标。
* @param [IN] y 锚点纵坐标。
*/
void BuildLayout(const gd::RectF& bounds, int x, int y);
/**
* @brief 查询坐标命中的菜单项下标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [OUT] swatchIndex 命中的色块下标(非色块行为 -1)。
* @return 项目下标,未命中返回 -1。
*/
int ItemAtPoint(int x, int y, int& swatchIndex) const;
/**
* @brief 将悬停项移动到下一个可用项。
* @param [IN] step 步进(+1 向下,-1 向上)。
*/
void MoveHover(int step);
/**
* @brief 确认当前悬停项并生成结果。
* @param [OUT] result 选择结果。
* @return 生成了有效选择返回 true。
*/
bool CommitHover(MenuResult& result);
/**
* @brief 计算指定下标项的内联输入框矩形(EDIT 项)。
* @param [IN] index 项目下标。
* @return 输入框矩形,非法下标返回空矩形。
*/
gd::RectF EditFieldRect(int index) const;
/**
* @brief 计算 EDIT 项**编辑态单位后缀**所占的宽度(0 表示无后缀)。
*
* 排版、命中测试与绘制共用:输入框正文区会向右让出这么多宽度,
* 后缀文本绘制在它右侧(靠右对齐)。
*
* @param [IN] item 菜单项。
* @return 后缀宽度(像素)。
*/
float EditSuffixWidth(const MenuItem& item) const;
/**
* @brief 计算 `HEADER_ACTION` 项右侧**图标按钮**(如「还原」)的矩形。
*
* 图标紧贴该行右端(右边距与其它行一致、行内垂直居中),大小按缩放比例推导。
* 鼠标悬停时该图标会高亮,点击即下发该项的命令。
*
* @param [IN] index 项目下标。
* @return 图标矩形;非 `HEADER_ACTION` 项或非法下标返回空矩形。
*/
gd::RectF HeaderIconRect(int index) const;
/**
* @brief 计算 EDIT 项输入框的“自然宽度”(排版与绘制共用同一公式)。
* @param [IN] item 菜单项。
* @return 宽度(像素)。
*/
float NaturalEditFieldWidth(const MenuItem& item) const;
/**
* @brief 进入指定项的内联编辑状态。
* @param [IN] index 项目下标。
* @return 成功进入返回 true。
*/
bool BeginItemEdit(int index);
/**
* @brief 结束内联编辑。
* @param [IN] commit 是否提交(false 为放弃修改)。
* @param [OUT] result 提交时输出 editCommit 结果。
* @return 之前处于编辑状态返回 true。
*/
bool EndItemEdit(bool commit, MenuResult& result);
/**
* @brief 依据当前项集合重新排布(用于值文本变化后)。
*/
void RebuildLayout();
/**
* @brief 退出内联编辑状态(不产生任何结果)。
*/
void CloseEditState();
/**
* @brief 展开指定项的二级子菜单(项不存在或非 SUBMENU 时等价于关闭)。
* @param [IN] parentIndex 父项下标。
*/
void BuildSubLayout(int parentIndex);
/**
* @brief 关闭二级子菜单并清空其状态。
*/
void CloseSubmenu();
/**
* @brief 查询坐标命中的子菜单项下标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 子菜单项下标,未命中返回 -1。
*/
int SubItemAtPoint(int x, int y) const;
/**
* @brief 绘制二级子菜单(含投影与全部条目)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintSubmenu(gd::Graphics& g);
const UiFonts* m_fonts = nullptr; /**< 字体资源。 */
const Theme* m_theme = nullptr; /**< 主题资源。 */
float m_scale = 1.0f; /**< DPI 缩放比例。 */
bool m_open = false; /**< 是否处于打开状态。 */
MenuScope m_scope = MenuScope::NONE; /**< 来源组件标识。 */
std::vector<MenuItem> m_items; /**< 菜单项集合。 */
gd::RectF m_rect; /**< 菜单整体矩形。 */
std::vector<gd::RectF> m_itemRects; /**< 各项矩形。 */
/**
* 同一次排布中,**带说明文字的 EDIT 项**共用的输入框宽度(0 表示没有这类项)。
*
* 宽度取这些项里最宽的自然宽度,并在排布时按“不能挤掉说明文字 / 不超过内容区”夹好。
* 同一面板内的输入框因此一样宽、右边界对齐(用户要求「Y轴」与「时间轴」等宽对齐,
* 而不是各自按自己的文本长度自适应)。
*/
float m_editFieldWidth = 0.0f;
std::vector<std::vector<gd::RectF>> m_swatchRects; /**< 各项内的色块矩形。 */
int m_hoverIndex = -1; /**< 当前悬停项。 */
int m_hoverIconIndex = -1; /**< 当前悬停的 `HEADER_ACTION` 图标所在项下标。 */
int m_hoverSwatch = -1; /**< 当前悬停色块。 */
int m_pressedIndex = -1; /**< 按下时的项目下标。 */
int m_pressedSwatch = -1; /**< 按下时的色块下标。 */
HWND m_host = nullptr; /**< 剪贴板所有者窗口句柄。 */
TextEdit m_edit; /**< 内联输入框编辑模型。 */
int m_editIndex = -1; /**< 正在编辑的项目下标,-1 表示未编辑。 */
gd::RectF m_buildBounds; /**< 最近一次排布使用的宿主矩形。 */
int m_buildX = 0; /**< 最近一次排布使用的锚点横坐标。 */
int m_buildY = 0; /**< 最近一次排布使用的锚点纵坐标。 */
bool m_subOpen = false; /**< 二级子菜单是否展开。 */
int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */
gd::RectF m_subRect; /**< 子菜单整体矩形。 */
std::vector<MenuItem> m_subItems; /**< 子菜单项集合。 */
std::vector<gd::RectF> m_subItemRects; /**< 子菜单各项矩形。 */
int m_subHoverIndex = -1; /**< 子菜单当前悬停项。 */
int m_subPressedIndex = -1; /**< 子菜单按下时的项下标。 */
};
} // namespace jdi
#endif // __POPUP_MENU_HPP__