Files
Just-DataInspector/Utilities/Inc/ColorPickerPopup.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

336 lines
11 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 __COLOR_PICKER_POPUP_HPP__
#define __COLOR_PICKER_POPUP_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <array>
#include <cstdint>
#include <memory>
#include "PaintUtil.hpp"
#include "Theme.hpp"
namespace jdi
{
/**
* @brief 浮动式 RGB 自定义颜色拾取器。
*
* 与弹出菜单一致,不创建独立窗口,而是作为宿主绘制的最后一步直接画到后备缓冲区,
* 以浮动面板的形式出现在触发控件附近;取值变化实时生效,点击面板外部即收起。
*/
class ColorPickerPopup
{
public:
ColorPickerPopup();
/**
* @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] bounds 宿主客户区矩形,用于把面板限制在窗口内。
* @param [IN] anchor 触发控件矩形(客户区坐标),面板会紧贴其下方或上方展开。
* @param [IN] color 初始颜色(0xAARRGGBB,仅取 RGB 分量)。
*/
void Open(const gd::RectF& bounds, const gd::RectF& anchor, std::uint32_t color);
/**
* @brief 关闭拾色面板。
*/
void Close();
/**
* @brief 判断拾色面板是否处于打开状态。
* @return 打开返回 true。
*/
bool IsOpen() const { return m_open; }
/**
* @brief 获取当前取色结果。
* @return 0xAARRGGBB 颜色值(Alpha 固定为不透明)。
*/
std::uint32_t CurrentColor() const;
/**
* @brief 取出并清除“取值已变化”标记。
* @param [OUT] color 变化后的颜色。
* @return 自上次调用以来取值发生过变化返回 true。
*/
bool TakeChanged(std::uint32_t& color);
/**
* @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 客户区纵坐标。
* @return 事件已被消费返回 true。
*/
bool OnLButtonDown(int x, int y);
/**
* @brief 处理鼠标左键抬起。
* @return 需要重绘返回 true。
*/
bool OnLButtonUp();
/**
* @brief 处理键盘按键(Esc 收起、回车提交输入)。
* @param [IN] key 虚拟键码。
* @return 事件已被消费返回 true。
*/
bool OnKeyDown(WPARAM key);
/**
* @brief 处理字符输入。
* @param [IN] character 输入字符('\r' 提交、'\b' 退格、0x03 复制)。
* @return 界面状态发生变化返回 true。
*/
bool OnChar(wchar_t character);
/**
* @brief 判断面板是否处于内联文本输入状态。
* @return 正在输入返回 true。
*/
bool IsEditing() const { return m_editField != EditField::NONE; }
/**
* @brief 编辑态下按下鼠标右键:取消选区并把插入符移到文本末尾。
* @return 发生改变返回 true;不在编辑态返回 false。
*/
bool CollapseEditSelectionToEnd();
/**
* @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] owner 宿主窗口句柄,允许为空。
*/
void SetOwnerWindow(HWND owner) { m_owner = owner; }
/**
* @brief 判断坐标是否落在拾色面板内部。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 落在内部返回 true。
*/
bool ContainsPoint(int x, int y) const;
/**
* @brief 查询该坐标应使用的系统光标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 系统光标句柄;面板未打开时返回空指针。
*/
HCURSOR QueryCursor(int x, int y) const;
private:
/**
* @brief 面板内可进行内联文本输入的字段。
*/
enum class EditField : int
{
NONE = 0, /**< 未处于编辑状态。 */
R = 1, /**< 红色分量(0-255)。 */
G = 2, /**< 绿色分量(0-255)。 */
B = 3, /**< 蓝色分量(0-255)。 */
HEX = 4 /**< 十六进制色号。 */
};
/**
* @brief 面板内正在拖拽的控件。
*/
enum class DragTarget : int
{
NONE = 0, /**< 未拖拽。 */
SLIDER = 1, /**< RGB 分量滑块(具体下标见 `m_draggingSlider`)。 */
GAMUT = 2, /**< 色域方块(饱和度-明度平面)。 */
HUE = 3 /**< 色相条。 */
};
/**
* @brief 依据锚点矩形与客户区计算面板矩形与内部控件矩形。
* @param [IN] bounds 宿主客户区矩形。
* @param [IN] anchor 触发控件矩形。
*/
void BuildLayout(const gd::RectF& bounds, const gd::RectF& anchor);
/**
* @brief 进入指定字段的内联输入状态。
* @param [IN] field 目标字段。
* @return 状态发生变化返回 true。
*/
bool BeginEdit(EditField field);
/**
* @brief 结束内联输入状态。
* @param [IN] commit 是否提交输入值。
* @return 状态发生变化返回 true。
*/
bool EndEdit(bool commit);
/**
* @brief 计算当前输入框的矩形。
* @return 输入框矩形。
*/
gd::RectF EditFieldRect() const;
/**
* @brief 把“文本区矩形”扩成**编辑态输入框**矩形(向外扩出输入框的内边距)。
*
* ⚠️ 数值区正好 3 个字符宽、色号正好 6+1 个字符宽,若直接拿文本区当输入框,
* 输入框内部的 6px 内边距会把最后一个字符裁掉。
*
* @param [IN] textRect 文本区矩形(数值区或色号区)。
* @return 输入框矩形。
*/
gd::RectF PaddedEditRect(const gd::RectF& textRect) const;
/**
* @brief 查询坐标命中的滑块下标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 滑块下标(0=R,1=G,2=B),未命中返回 -1。
*/
int SliderAtPoint(int x, int y) const;
/**
* @brief 计算通道标签(R/G/B)的矩形。
*
* 标签**右边界**到轨道左端的距离与轨道右端到数值区的距离相等
* (用户要求:滑块两侧留白一致,三个字母纵向对齐)。
*
* @param [IN] index 通道下标(0=R,1=G,2=B)。
* @return 标签矩形;下标非法时返回空矩形。
*/
gd::RectF ChannelLabelRect(int index) const;
/**
* @brief 应用滑块拖拽位置到对应通道。
* @param [IN] index 滑块下标。
* @param [IN] x 客户区横坐标。
*/
void ApplySlider(int index, float x);
/**
* @brief 在色域方块内按下/拖拽:按位置取出饱和度与明度。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
*/
void ApplyGamut(float x, float y);
/**
* @brief 在色相条内按下/拖拽:按纵向位置取出色相。
* @param [IN] y 客户区纵坐标。
*/
void ApplyHue(float y);
/**
* @brief 当前有效色相(0-1)。灰度/黑色时 HSV 的色相无定义,沿用上一次的有效值。
* @return 色相。
*/
double CurrentHue() const;
/**
* @brief RGB 分量变化后刷新派生的色相与饱和度/明度选择状态。
*/
void RefreshSelectionFromChannels();
/**
* @brief 销毁色域方块缓存位图(尺寸或色相变化时重建)。
*/
void InvalidateGamutCache();
/**
* @brief 确保色域方块缓存与当前色相/缩放匹配(必要时重建)。
* @return 可用返回 true。
*/
bool EnsureGamutCache();
const UiFonts* m_fonts = nullptr; /**< 字体资源。 */
const Theme* m_theme = nullptr; /**< 主题资源。 */
HWND m_owner = nullptr; /**< 剪贴板所有者窗口句柄。 */
float m_scale = 1.0f; /**< DPI 缩放比例。 */
bool m_open = false; /**< 是否处于打开状态。 */
bool m_changed = false; /**< 取值是否发生变化。 */
int m_draggingSlider = -1; /**< 正在拖拽的滑块下标,-1 表示未拖拽。 */
int m_hoverSlider = -1; /**< 当前悬停的滑块下标。 */
DragTarget m_drag = DragTarget::NONE; /**< 正在拖拽的控件。 */
std::array<double, 3> m_channels = {0.0, 0.0, 0.0}; /**< RGB 分量(0-1,唯一数据源)。 */
double m_hue = 0.0; /**< 记忆色相(0-1);灰度/黑色时保留上次取值。 */
/**
* @brief 色域方块上的选点位置(饱和度/明度,均为 0-1)。
*
* 必须**与 RGB 分开保存**:黑色/灰色的饱和度无定义,若每帧都从 RGB 反推,
* 把选点拖到方块右边界之外(明度归零)时选点会瞬间弹回最左侧。
*/
double m_sat = 0.0;
double m_val = 0.0;
gd::RectF m_rect; /**< 面板整体矩形。 */
gd::RectF m_titleRect; /**< 标题区域。 */
std::array<gd::RectF, 3> m_tracks; /**< 滑块轨道矩形。 */
std::array<gd::RectF, 3> m_values; /**< 数值文本矩形。 */
gd::RectF m_previewRect; /**< 预览色块矩形。 */
gd::RectF m_hexRect; /**< 十六进制文本矩形。 */
gd::RectF m_gamutRect; /**< 色域方块矩形(横轴 S,纵轴 V)。 */
gd::RectF m_hueRect; /**< 色相条矩形(纵轴色相)。 */
/** 色域方块缓存位图:按当前色相预渲染,避免每帧重画数十条渐变。 */
std::unique_ptr<gd::Bitmap> m_gamutCache;
double m_gamutCacheHue = -1.0; /**< 缓存对应的色相。 */
int m_gamutCacheSize = 0; /**< 缓存位图边长(像素)。 */
EditField m_editField = EditField::NONE; /**< 当前处于输入状态的字段。 */
TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */
};
} // namespace jdi
#endif // __COLOR_PICKER_POPUP_HPP__