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

1096 lines
34 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 "ColorPickerPopup.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include "Utf8Util.hpp"
namespace jdi
{
namespace
{
const float kPanelWidth = 324.0f; /**< 面板宽度(逻辑像素)。 */
const float kPadH = 12.0f; /**< 面板左右内边距。 */
const float kPadV = 10.0f; /**< 面板上下内边距。 */
const float kTitleHeight = 20.0f; /**< 标题行高度。 */
const float kTitleGap = 8.0f; /**< 标题与内容区间距。 */
const float kRowHeight = 26.0f; /**< 单个滑块行高度。 */
const float kRowGap = 2.0f; /**< 滑块行间距。 */
const float kPreviewHeight = 24.0f; /**< 预览行(预览色块 + 色号)高度。 */
const float kLabelWidth = 16.0f; /**< 通道标签宽度。 */
/**
* 滑块**两侧**的间隙(左:通道字母 ↔ 轨道;右:轨道 ↔ 数值区)。
*
* ⚠️ 必须共用同一个常量:用户明确要求轨道左右两侧的留白相等。
*/
const float kSliderGap = 8.0f;
const float kTrackHeight = 8.0f; /**< 滑块轨道高度。 */
const float kCorner = 6.0f; /**< 面板圆角半径。 */
/** 色域方块 / 色相条 / 预览色块 / 色号框共用同一圆角(视觉上统一)。 */
const float kSwatchCorner = 3.0f;
const float kGamutSize = 112.0f; /**< 色域方块边长。 */
const float kHueWidth = 14.0f; /**< 色相条宽度。 */
const float kGamutGap = 10.0f; /**< 色域方块与色相条间距。 */
const float kColumnGap = 12.0f; /**< 色相条与滑块列间距。 */
const float kSwatchWidth = 52.0f; /**< 预览色块宽度。 */
const float kSwatchHexGap = 8.0f; /**< 预览色块与色号间距(下限,空间不足时以右对齐优先)。 */
const float kGamutBorder = 1.0f; /**< 色域方块/色相条描边宽度。 */
/** @brief 通道显示名称。 */
const wchar_t* kChannelNames[3] = {L"R", L"G", L"B"};
/** @brief 通道滑块填充色。 */
const std::uint32_t kChannelColors[3] = {0xFFDC322Fu, 0xFF859900u, 0xFF268BD2u};
/**
* @brief 把数值限制到 0-1。
* @param [IN] value 输入值。
* @return 限制后的值。
*/
double Clamp01(double value)
{
if (value < 0.0)
{
return 0.0;
}
if (value > 1.0)
{
return 1.0;
}
return value;
}
/**
* @brief HSV 颜色(色相/饱和度/明度,均为 0-1)。
*/
struct Hsv
{
double h = 0.0; /**< 色相。 */
double s = 0.0; /**< 饱和度。 */
double v = 0.0; /**< 明度。 */
};
/**
* @brief RGB(0-1 分量)转 HSV。
* @param [IN] rgb RGB 分量。
* @return HSV 值(灰色时色相为 0)。
*/
Hsv ToHsv(const std::array<double, 3>& rgb)
{
const double maximum = std::max(rgb[0], std::max(rgb[1], rgb[2]));
const double minimum = std::min(rgb[0], std::min(rgb[1], rgb[2]));
const double delta = maximum - minimum;
Hsv hsv;
hsv.v = maximum;
hsv.s = (maximum > 1.0e-9) ? (delta / maximum) : 0.0;
if (delta <= 1.0e-9)
{
hsv.h = 0.0;
return hsv;
}
double hue = 0.0;
if (maximum == rgb[0])
{
hue = (rgb[1] - rgb[2]) / delta;
}
else if (maximum == rgb[1])
{
hue = 2.0 + (rgb[2] - rgb[0]) / delta;
}
else
{
hue = 4.0 + (rgb[0] - rgb[1]) / delta;
}
hue /= 6.0;
if (hue < 0.0)
{
hue += 1.0;
}
hsv.h = hue;
return hsv;
}
/**
* @brief HSV 转 RGB(0-1 分量)。
* @param [IN] h 色相(0-1,自动取模)。
* @param [IN] s 饱和度(0-1)。
* @param [IN] v 明度(0-1)。
* @return RGB 分量。
*/
std::array<double, 3> FromHsv(double h, double s, double v)
{
h = h - std::floor(h);
s = Clamp01(s);
v = Clamp01(v);
if (s <= 1.0e-9)
{
return {v, v, v};
}
const double sector = h * 6.0;
const int index = static_cast<int>(std::floor(sector)) % 6;
const double fraction = sector - std::floor(sector);
const double p = v * (1.0 - s);
const double q = v * (1.0 - s * fraction);
const double t = v * (1.0 - s * (1.0 - fraction));
switch (index)
{
case 0:
return {v, t, p};
case 1:
return {q, v, p};
case 2:
return {p, v, t};
case 3:
return {p, q, v};
case 4:
return {t, p, v};
default:
return {v, p, q};
}
}
/**
* @brief 绘制水平滑块(与样式区共用同一实现,保证行为一致)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] track 轨道矩形。
* @param [IN] ratio 当前值比例(0-1)。
* @param [IN] fillColor 已填充部分颜色。
* @param [IN] hovered 是否处于悬停或拖拽状态。
* @param [IN] scale 缩放比例。
*/
void DrawChannelSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor,
bool hovered, float scale)
{
UiDrawSlider(g, track, ratio, fillColor, hovered, scale);
}
/**
* @brief 将 0-1 分量换算为 0-255 整数。
* @param [IN] value 分量值。
* @return 整数分量。
*/
unsigned int ToByte(double value)
{
const double scaled = value * 255.0 + 0.5;
if (scaled < 0.0)
{
return 0u;
}
if (scaled > 255.0)
{
return 255u;
}
return static_cast<unsigned int>(scaled);
}
}
ColorPickerPopup::ColorPickerPopup() = default;
void ColorPickerPopup::Setup(const UiFonts* fonts, const Theme* theme)
{
m_fonts = fonts;
m_theme = theme;
}
void ColorPickerPopup::SetScale(float scale)
{
m_scale = (scale > 0.1f) ? scale : 1.0f;
}
std::uint32_t ColorPickerPopup::CurrentColor() const
{
const unsigned int r = ToByte(m_channels[0]);
const unsigned int g = ToByte(m_channels[1]);
const unsigned int b = ToByte(m_channels[2]);
return 0xFF000000u | (r << 16) | (g << 8) | b;
}
bool ColorPickerPopup::TakeChanged(std::uint32_t& color)
{
if (!m_changed)
{
return false;
}
m_changed = false;
color = CurrentColor();
return true;
}
void ColorPickerPopup::BuildLayout(const gd::RectF& bounds, const gd::RectF& anchor)
{
// 布局:左侧一列放"色域方块 + 色相条",右侧一列**上方**放"预览色块 + 色号"、
// **下方**放 R/G/B 三行(每行 = 通道标签 + 滑块 + 数值)。
// 右列总高 = 预览行 + 间隙 + 三行滑块,与左侧"色域方块"高度**严格相等**,
// 因此两列上下边对齐、整体版面协调(不再有单起一行的底部预览条)。
// 所有控件都从面板内边距起算,因此不会出现越界。
const float width = kPanelWidth * m_scale;
const float height =
(kPadV * 2.0f + kTitleHeight + kTitleGap + kGamutSize) * m_scale;
const float gap = 4.0f * m_scale;
// 纵向:优先紧贴触发控件下方展开;空间不足时改到其上方,避免遮挡触发控件本身。
float top = anchor.Y + anchor.Height + gap;
if (top + height > bounds.Y + bounds.Height)
{
top = anchor.Y - height - gap;
}
top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f));
// 横向:与触发控件左对齐,越界时再收敛。
float left = anchor.X;
if (left + width > bounds.X + bounds.Width)
{
left = anchor.X + anchor.Width - width;
}
left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f));
m_rect = gd::RectF(left, top, width, height);
m_titleRect = gd::RectF(left + kPadH * m_scale, top + kPadV * m_scale,
width - kPadH * 2.0f * m_scale, kTitleHeight * m_scale);
const float contentTop = m_titleRect.Y + m_titleRect.Height + kTitleGap * m_scale;
const float contentLeft = left + kPadH * m_scale;
const float contentRight = left + width - kPadH * m_scale;
// 左列:色域方块(横轴饱和度、纵轴明度)+ 色相条。
m_gamutRect = gd::RectF(contentLeft, contentTop, kGamutSize * m_scale, kGamutSize * m_scale);
m_hueRect = gd::RectF(m_gamutRect.X + m_gamutRect.Width + kGamutGap * m_scale, contentTop,
kHueWidth * m_scale, kGamutSize * m_scale);
// 右列:预览行在滑块之上,整列高度与左侧方块一致。
const float leftColumnWidth = (kGamutSize + kGamutGap + kHueWidth) * m_scale;
const float columnLeft = m_gamutRect.X + leftColumnWidth + kColumnGap * m_scale;
const float columnWidth = std::max(60.0f * m_scale, contentRight - columnLeft);
// 预览行与滑块之间的间隙:正好补齐"方块高度 − 预览行高 − 三行滑块高",
// 使右列总高与左侧色域方块严格相等(常量变化时自动适配)。
const float previewRowGap =
std::max(0.0f, kGamutSize - kPreviewHeight - (kRowHeight * 3.0f + kRowGap * 2.0f)) * m_scale;
// 通道数值区固定占 **3 个字符宽**(0-255 最多三位):与数字本身的宽度无关,
// 这样三行数值的右边界严格一致(右对齐后三列数字纵向对齐)。
const float valueWidth =
std::max(1.0f, UiMeasureTextWidth(m_fonts->monoSmall, L"000"));
// 轨道两侧用**同一个**间隙(用户要求两侧留白相等)。
const float sliderGap = kSliderGap * m_scale;
const float labelWidth = kLabelWidth * m_scale;
const float trackLeft = columnLeft + labelWidth + sliderGap;
const float trackWidth = std::max(
24.0f, columnWidth - (labelWidth + sliderGap + sliderGap + valueWidth));
const float valueLeft = trackLeft + trackWidth + sliderGap;
const float valueRight = valueLeft + valueWidth;
// 预览色块的**左边界与轨道左边界对齐**(长宽不变)。
m_previewRect =
gd::RectF(trackLeft, contentTop, kSwatchWidth * m_scale, kPreviewHeight * m_scale);
// 色号 "#RRGGBB" 固定 **6+1 个字符宽**,并且**右边界与三行数值的右边界对齐**。
const float hexWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"#000000");
const float hexLeft = std::max(m_previewRect.X + m_previewRect.Width + kSwatchHexGap * m_scale,
valueRight - hexWidth);
m_hexRect = gd::RectF(hexLeft, contentTop, valueRight - hexLeft, kPreviewHeight * m_scale);
const float sliderTop = m_previewRect.Y + m_previewRect.Height + previewRowGap;
for (int index = 0; index < 3; ++index)
{
const float rowTop = sliderTop + static_cast<float>(index) * (kRowHeight + kRowGap) * m_scale;
m_tracks[static_cast<std::size_t>(index)] =
gd::RectF(trackLeft, rowTop + (kRowHeight * m_scale - kTrackHeight * m_scale) * 0.5f,
trackWidth, kTrackHeight * m_scale);
m_values[static_cast<std::size_t>(index)] =
gd::RectF(valueLeft, rowTop, valueWidth, kRowHeight * m_scale);
}
}
void ColorPickerPopup::Open(const gd::RectF& bounds, const gd::RectF& anchor, std::uint32_t color)
{
m_channels[0] = static_cast<double>((color >> 16) & 0xFFu) / 255.0;
m_channels[1] = static_cast<double>((color >> 8) & 0xFFu) / 255.0;
m_channels[2] = static_cast<double>(color & 0xFFu) / 255.0;
m_open = true;
m_changed = false;
m_draggingSlider = -1;
m_hoverSlider = -1;
m_drag = DragTarget::NONE;
// 记住初始色相与选点位置:之后即使把明度拖到 0(黑色)色相/饱和度也不会丢失。
RefreshSelectionFromChannels();
InvalidateGamutCache();
BuildLayout(bounds, anchor);
}
void ColorPickerPopup::Close()
{
// 收起前先提交正在编辑的取值,避免用户输入被静默丢弃。
if (m_editField != EditField::NONE)
{
EndEdit(true);
}
m_open = false;
m_draggingSlider = -1;
m_hoverSlider = -1;
m_drag = DragTarget::NONE;
// 释放色域方块缓存(它是 GDI+ 对象,必须在 GdiplusShutdown 之前销毁)。
// 下次打开时按需重建,代价只有一张小位图的填充。
m_gamutCache.reset();
m_gamutCacheSize = 0;
m_gamutCacheHue = -1.0;
}
double ColorPickerPopup::CurrentHue() const
{
// 直接返回保存的色相选择值(不再从 RGB 反推):
// 色相 1 与 0 是同一个颜色(红),反推会把它们归一到 0,
// 表现为"把滑块拖到最下面却弹回最上面"。
// `RefreshSelectionFromChannels` 会在改色(RGB 滑块 / 色号)后同步这个值。
return m_hue;
}
void ColorPickerPopup::RefreshSelectionFromChannels()
{
const Hsv hsv = ToHsv(m_channels);
// 饱和度足够大时才认色相(灰度时色相无定义,沿用上次取值)。
if (hsv.s > 1.0e-4)
{
m_hue = hsv.h;
}
// 饱和度/明度总是可以从 RGB 反推(明度=最大值,饱和度=max>0 ? delta/max : 0)。
// 注意这是"由 RGB 派生"的路径,只用于外部改色(滑块/色号)后的同步;
// 直接用色域方块时不会走这里,以免黑色把饱和度重置为 0。
m_sat = hsv.s;
m_val = hsv.v;
}
void ColorPickerPopup::InvalidateGamutCache()
{
m_gamutCacheHue = -1.0;
}
bool ColorPickerPopup::EnsureGamutCache()
{
const int size = std::max(8, static_cast<int>(std::lround(kGamutSize * m_scale)));
if (m_gamutCache == nullptr || m_gamutCacheSize != size)
{
m_gamutCache.reset(new gd::Bitmap(size, size, PixelFormat32bppRGB));
m_gamutCacheSize = size;
m_gamutCacheHue = -1.0;
}
const double hue = CurrentHue();
if (std::fabs(m_gamutCacheHue - hue) < 1.0e-4)
{
return true;
}
gd::BitmapData data = {};
const gd::Rect lockRect(0, 0, size, size);
if (m_gamutCache->LockBits(&lockRect, gd::ImageLockModeWrite, PixelFormat32bppRGB, &data) != gd::Ok)
{
return false;
}
unsigned char* base = static_cast<unsigned char*>(data.Scan0);
for (int y = 0; y < size; ++y)
{
// 纵轴明度(上 1 → 下 0),横轴饱和度(左 0 → 右 1)——与常见取色器一致。
const double value = 1.0 - (static_cast<double>(y) + 0.5) / static_cast<double>(size);
unsigned char* row = (data.Stride >= 0)
? (base + static_cast<std::ptrdiff_t>(y) * data.Stride)
: (base + static_cast<std::ptrdiff_t>(size - 1 - y) * (-data.Stride));
for (int x = 0; x < size; ++x)
{
const double saturation =
(static_cast<double>(x) + 0.5) / static_cast<double>(size);
const std::array<double, 3> rgb = FromHsv(hue, saturation, value);
row[x * 4 + 0] = static_cast<unsigned char>(ToByte(rgb[2])); // B
row[x * 4 + 1] = static_cast<unsigned char>(ToByte(rgb[1])); // G
row[x * 4 + 2] = static_cast<unsigned char>(ToByte(rgb[0])); // R
row[x * 4 + 3] = 0xFF;
}
}
m_gamutCache->UnlockBits(&data);
m_gamutCacheHue = hue;
return true;
}
void ColorPickerPopup::ApplyGamut(float x, float y)
{
if (m_gamutRect.Width <= 0.0f || m_gamutRect.Height <= 0.0f)
{
return;
}
// 越出方块时夹取到最近边界(而不是丢失位置):鼠标拖到方块之外时选点
// 会停在对应角落,与鼠标相对方块的方位一致。
const double saturation = Clamp01((x - m_gamutRect.X) / m_gamutRect.Width);
const double value = 1.0 - Clamp01((y - m_gamutRect.Y) / m_gamutRect.Height);
if (std::fabs(saturation - m_sat) < 1.0e-9 && std::fabs(value - m_val) < 1.0e-9)
{
return;
}
// 先记录选点位置,再推颜色:两者分开保存,明度为 0 时饱和度不会因此丢失。
m_sat = saturation;
m_val = value;
m_channels = FromHsv(CurrentHue(), m_sat, m_val);
m_changed = true;
}
void ColorPickerPopup::ApplyHue(float y)
{
if (m_hueRect.Height <= 0.0f)
{
return;
}
// 越出色相条时同样夹取:拖到下方停在最下面(色相 1),不会因 0/1 等价而弹回顶部。
const double hue = Clamp01((y - m_hueRect.Y) / m_hueRect.Height);
if (std::fabs(hue - m_hue) < 1.0e-9)
{
return;
}
m_hue = hue;
m_channels = FromHsv(m_hue, m_sat, m_val);
m_changed = true;
}
gd::RectF ColorPickerPopup::EditFieldRect() const
{
switch (m_editField)
{
case EditField::R:
return PaddedEditRect(m_values[0]);
case EditField::G:
return PaddedEditRect(m_values[1]);
case EditField::B:
return PaddedEditRect(m_values[2]);
case EditField::HEX:
return PaddedEditRect(m_hexRect);
case EditField::NONE:
default:
return gd::RectF();
}
}
gd::RectF ColorPickerPopup::PaddedEditRect(const gd::RectF& textRect) const
{
// 与 `UiDrawEditField` 的内边距(6*scale)一致:文本区向外扩这么多,
// 编辑态才能完整显示 3 位数值 / 7 位色号(否则最后一个字符被裁掉)。
const float padding = 6.0f * m_scale;
return gd::RectF(textRect.X - padding, textRect.Y, textRect.Width + padding * 2.0f,
textRect.Height);
}
gd::RectF ColorPickerPopup::ChannelLabelRect(int index) const
{
if (index < 0 || index >= 3)
{
return gd::RectF();
}
const std::size_t slot = static_cast<std::size_t>(index);
const gd::RectF& track = m_tracks[slot];
// 间隙直接由实际几何推出(轨道右端 → 数值区左端),因此标签侧与数值侧**必然**相等。
const float gap = std::max(0.0f, m_values[slot].X - (track.X + track.Width));
return gd::RectF(track.X - gap - kLabelWidth * m_scale, m_values[slot].Y,
kLabelWidth * m_scale, kRowHeight * m_scale);
}
bool ColorPickerPopup::BeginEdit(EditField field)
{
if (!m_open || field == EditField::NONE)
{
return false;
}
if (m_editField == field)
{
return false;
}
switch (field)
{
case EditField::R:
case EditField::G:
case EditField::B:
{
const int index = static_cast<int>(field) - 1;
m_edit.Begin(std::to_wstring(ToByte(m_channels[static_cast<std::size_t>(index)])), true,
EditFilter::BYTE, 0u);
break;
}
case EditField::HEX:
{
const std::uint32_t color = CurrentColor();
wchar_t buffer[16];
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%02X%02X%02X", (color >> 16) & 0xFFu,
(color >> 8) & 0xFFu, color & 0xFFu);
m_edit.Begin(std::wstring(buffer), true, EditFilter::HEX, 0u);
break;
}
default:
return false;
}
m_draggingSlider = -1;
m_editField = field;
return true;
}
bool ColorPickerPopup::EndEdit(bool commit)
{
if (m_editField == EditField::NONE)
{
return false;
}
const EditField field = m_editField;
const std::wstring text = m_edit.Text();
m_editField = EditField::NONE;
m_edit.End();
if (!commit)
{
return true;
}
const std::wstring trimmed = Trim(text);
if (field == EditField::HEX)
{
// 接受带或不带 '#' 的 6 位十六进制色号。
std::wstring digits = trimmed;
if (!digits.empty() && digits.front() == L'#')
{
digits.erase(digits.begin());
}
if (digits.size() == 6)
{
unsigned int value = 0;
bool valid = true;
for (const wchar_t c : digits)
{
unsigned int digit = 0;
if (c >= L'0' && c <= L'9')
{
digit = static_cast<unsigned int>(c - L'0');
}
else if (c >= L'a' && c <= L'f')
{
digit = static_cast<unsigned int>(c - L'a') + 10u;
}
else if (c >= L'A' && c <= L'F')
{
digit = static_cast<unsigned int>(c - L'A') + 10u;
}
else
{
valid = false;
break;
}
value = (value << 4) | digit;
}
if (valid)
{
m_channels[0] = static_cast<double>((value >> 16) & 0xFFu) / 255.0;
m_channels[1] = static_cast<double>((value >> 8) & 0xFFu) / 255.0;
m_channels[2] = static_cast<double>(value & 0xFFu) / 255.0;
RefreshSelectionFromChannels();
m_changed = true;
}
}
return true;
}
if (trimmed.empty())
{
return true;
}
wchar_t* stop = nullptr;
const double parsed = std::wcstod(trimmed.c_str(), &stop);
if (stop == trimmed.c_str() || parsed != parsed)
{
return true;
}
double value = parsed;
if (value < 0.0)
{
value = 0.0;
}
if (value > 255.0)
{
value = 255.0;
}
const int index = static_cast<int>(field) - 1;
if (index >= 0 && index < 3)
{
m_channels[static_cast<std::size_t>(index)] = value / 255.0;
RefreshSelectionFromChannels();
m_changed = true;
}
return true;
}
bool ColorPickerPopup::OnChar(wchar_t character)
{
if (m_editField == EditField::NONE)
{
return false;
}
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
switch (character)
{
case 0x03: // Ctrl+C
m_edit.CopyToClipboard(m_owner);
return false;
case 0x18: // Ctrl+X
return m_edit.CutToClipboard(m_owner);
case 0x16: // Ctrl+V
return m_edit.PasteFromClipboard(m_owner);
case 0x01: // Ctrl+A
m_edit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
return EndEdit(true);
}
// 退格/删除已由 WM_KEYDOWN 的 OnKeyDown 处理,此处忽略控制字符避免重复删除。
if (character < 0x20 || character == 0x7F)
{
return false;
}
return m_edit.Insert(character);
}
bool ColorPickerPopup::CollapseEditSelectionToEnd()
{
if (m_editField == EditField::NONE)
{
return false;
}
return m_edit.DeselectToEnd();
}
bool ColorPickerPopup::OnEditMouseDown(int x, int y)
{
if (m_editField == EditField::NONE)
{
return false;
}
return UiEditFieldMouseDown(m_fonts->monoSmall, EditFieldRect(), m_edit, m_scale, x, y);
}
bool ColorPickerPopup::OnEditMouseMove(int x)
{
if (m_editField == EditField::NONE)
{
return false;
}
return UiEditFieldMouseMove(m_fonts->monoSmall, EditFieldRect(), m_edit, m_scale, x);
}
bool ColorPickerPopup::OnEditMouseUp()
{
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
bool ColorPickerPopup::ContainsPoint(int x, int y) const
{
if (!m_open)
{
return false;
}
return UiPointInRect(m_rect, static_cast<float>(x), static_cast<float>(y));
}
HCURSOR ColorPickerPopup::QueryCursor(int x, int y) const
{
if (!m_open)
{
return nullptr;
}
// 拖拽过程中始终保持对应光标,避免指针移出控件后光标跳变。
if (m_drag == DragTarget::GAMUT || UiPointInRect(m_gamutRect, static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_CROSS);
}
if (m_drag == DragTarget::HUE ||
UiPointInRect(m_hueRect, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
if (m_drag == DragTarget::SLIDER || m_draggingSlider >= 0 || SliderAtPoint(x, y) >= 0)
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
return LoadCursorW(nullptr, IDC_ARROW);
}
int ColorPickerPopup::SliderAtPoint(int x, int y) const
{
if (!m_open)
{
return -1;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (int index = 0; index < 3; ++index)
{
const gd::RectF& track = m_tracks[static_cast<std::size_t>(index)];
// 命中范围在纵向适当放宽,便于拖拽。
const gd::RectF hit(track.X - 4.0f * m_scale, track.Y - 9.0f * m_scale,
track.Width + 8.0f * m_scale, track.Height + 18.0f * m_scale);
if (UiPointInRect(hit, fx, fy))
{
return index;
}
}
return -1;
}
void ColorPickerPopup::ApplySlider(int index, float x)
{
if (index < 0 || index >= 3)
{
return;
}
const std::size_t slot = static_cast<std::size_t>(index);
const double ratio = UiSliderRatioFromX(m_tracks[slot], x);
if (std::fabs(ratio - m_channels[slot]) < 1.0e-6)
{
return;
}
m_channels[slot] = ratio;
RefreshSelectionFromChannels();
m_changed = true;
}
bool ColorPickerPopup::OnMouseMove(int x, int y)
{
if (!m_open)
{
return false;
}
switch (m_drag)
{
case DragTarget::GAMUT:
ApplyGamut(static_cast<float>(x), static_cast<float>(y));
return true;
case DragTarget::HUE:
ApplyHue(static_cast<float>(y));
return true;
case DragTarget::SLIDER:
ApplySlider(m_draggingSlider, static_cast<float>(x));
return true;
case DragTarget::NONE:
default:
break;
}
const int hover = SliderAtPoint(x, y);
if (hover == m_hoverSlider)
{
return false;
}
m_hoverSlider = hover;
return true;
}
bool ColorPickerPopup::OnLButtonDown(int x, int y)
{
if (!m_open)
{
return false;
}
if (!ContainsPoint(x, y))
{
// 点击面板外部:收起面板(外部点击不再下传,避免误触其他控件)。
Close();
return true;
}
// 点击当前输入框以外的位置时提交输入。
if (m_editField != EditField::NONE && !UiPointInRect(EditFieldRect(), static_cast<float>(x),
static_cast<float>(y)))
{
EndEdit(true);
}
// 数值与色号文本优先于滑块:两者几何重叠时要能进入输入状态。
// 命中区用**编辑框**矩形(比 3 / 7 个字符宽的文本区宽出输入框内边距):
// 文本区只有 3 个字符宽,命中区太小不便点击。
for (int index = 0; index < 3; ++index)
{
if (UiPointInRect(PaddedEditRect(m_values[static_cast<std::size_t>(index)]),
static_cast<float>(x), static_cast<float>(y)))
{
BeginEdit(static_cast<EditField>(index + 1));
return true;
}
}
if (UiPointInRect(PaddedEditRect(m_hexRect), static_cast<float>(x), static_cast<float>(y)))
{
BeginEdit(EditField::HEX);
return true;
}
// 色域方块与色相条:按下即取值,之后拖动持续更新。
if (UiPointInRect(m_gamutRect, static_cast<float>(x), static_cast<float>(y)))
{
m_drag = DragTarget::GAMUT;
ApplyGamut(static_cast<float>(x), static_cast<float>(y));
return true;
}
if (UiPointInRect(m_hueRect, static_cast<float>(x), static_cast<float>(y)))
{
m_drag = DragTarget::HUE;
ApplyHue(static_cast<float>(y));
return true;
}
m_draggingSlider = SliderAtPoint(x, y);
if (m_draggingSlider >= 0)
{
m_drag = DragTarget::SLIDER;
ApplySlider(m_draggingSlider, static_cast<float>(x));
return true;
}
return true;
}
bool ColorPickerPopup::OnLButtonUp()
{
if (!m_open || (m_draggingSlider < 0 && m_drag == DragTarget::NONE))
{
return false;
}
m_draggingSlider = -1;
m_drag = DragTarget::NONE;
return true;
}
bool ColorPickerPopup::OnKeyDown(WPARAM key)
{
if (!m_open)
{
return false;
}
if (m_editField != EditField::NONE)
{
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
switch (key)
{
case VK_RETURN:
EndEdit(true);
return true;
case VK_ESCAPE:
EndEdit(false);
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;
case 'A':
if (control)
{
m_edit.SelectAll();
}
return true;
default:
break;
}
return true;
}
if (key == VK_ESCAPE)
{
Close();
}
return true;
}
void ColorPickerPopup::Paint(gd::Graphics& g)
{
if (!m_open || m_theme == nullptr || m_fonts == nullptr || m_rect.Width <= 0.0f)
{
return;
}
// 投影。
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);
UiText(g, L"自定义颜色", m_fonts->uiSmallBold, m_titleRect, m_theme->textMuted, TextAlign::LEFT);
// ---- 色域方块(横轴饱和度、纵轴明度)----
// 方块按当前色相预渲染到缓存位图里,避免每帧重画上百条渐变;
// 色相变化时才重建一次。
//
// ⚠️ 填充必须**裁剪进圆角路径**里:
// * 缓存的位图是直角矩形,直接贴上去会在四个角的多出来像素(用户报告的"角落里出现
// 莫名其妙的黑色像素点"——方块左下/右下本身就是接近黑色的饱和/明度极值)。
// * `WrapModeTileFlipXY` 让采样到边界之外的插值镜像取边,不再混入透明黑,
// 边界也就不会再被"压暗"出一圈深色。
if (EnsureGamutCache())
{
gd::GraphicsPath gamutPath;
UiBuildRoundRectPath(gamutPath, m_gamutRect, kSwatchCorner * m_scale);
gd::ImageAttributes attributes;
attributes.SetWrapMode(gd::WrapModeTileFlipXY, gd::Color(), FALSE);
const int saveState = g.Save();
g.SetClip(&gamutPath);
g.DrawImage(m_gamutCache.get(), m_gamutRect, 0.0f, 0.0f,
static_cast<gd::REAL>(m_gamutCacheSize),
static_cast<gd::REAL>(m_gamutCacheSize), gd::UnitPixel, &attributes);
g.Restore(saveState);
}
UiDrawRoundRect(g, m_gamutRect, kSwatchCorner * m_scale, m_theme->border, kGamutBorder);
// ---- 色相条(纵向 0-1)----
{
// 一条带 7 个色标的线性渐变画满整条(0、1/6 … 1,每段内 RGB 线性):
// 与"6 段拼色"的颜色完全一致,但不会有段与段之间的接缝横线。
gd::Color stops[7];
gd::REAL positions[7];
for (int index = 0; index < 7; ++index)
{
const double hue = static_cast<double>(index) / 6.0;
const std::array<double, 3> rgb = FromHsv((hue > 1.0) ? 1.0 : hue, 1.0, 1.0);
stops[index] = ToGdiColor(0xFF000000u | (ToByte(rgb[0]) << 16) | (ToByte(rgb[1]) << 8) |
ToByte(rgb[2]));
positions[index] = static_cast<gd::REAL>(static_cast<double>(index) / 6.0);
}
gd::LinearGradientBrush brush(
gd::Point(static_cast<INT>(std::lround(m_hueRect.X)),
static_cast<INT>(std::lround(m_hueRect.Y))),
gd::Point(static_cast<INT>(std::lround(m_hueRect.X)),
static_cast<INT>(std::lround(m_hueRect.Y + m_hueRect.Height))),
stops[0], stops[6]);
brush.SetInterpolationColors(stops, positions, 7);
// 与色域方块**共用同一个圆角**(`kSwatchCorner`),并且同样裁剪进圆角路径:
// 直角填充会在圆角描边之外露出四个尖角(用户报告的"四个角的杂色像素点")。
gd::GraphicsPath huePath;
UiBuildRoundRectPath(huePath, m_hueRect, kSwatchCorner * m_scale);
const int saveState = g.Save();
g.SetClip(&huePath);
g.FillRectangle(&brush, m_hueRect);
g.Restore(saveState);
UiDrawRoundRect(g, m_hueRect, kSwatchCorner * m_scale, m_theme->border, kGamutBorder);
// ---- 色域方块上的选点:中心就是当前选中的颜色,外面只加一圈白色描边 ----
// 位置直接用保存的选点状态(不再从 RGB 反推,否则黑色会让选点弹回左侧)。
const float markerX = m_gamutRect.X + static_cast<float>(m_sat) * m_gamutRect.Width;
const float markerY = m_gamutRect.Y + static_cast<float>(1.0 - m_val) * m_gamutRect.Height;
const float markerRadius = 5.5f * m_scale;
gd::SolidBrush markerBrush(ToGdiColor(CurrentColor()));
g.FillEllipse(&markerBrush, markerX - markerRadius, markerY - markerRadius,
markerRadius * 2.0f, markerRadius * 2.0f);
gd::Pen markerPen(ToGdiColor(0xFFFFFFFFu), 2.0f * m_scale);
g.DrawEllipse(&markerPen, markerX - markerRadius, markerY - markerRadius,
markerRadius * 2.0f, markerRadius * 2.0f);
// ---- 色相条上的指示器:白色描边的圆角长方条,底色就是该位置的颜色 ----
// 色相直接用保存值:色相 1 与 0 是同一个颜色(红),从 RGB 反推会归一到 0,
// 表现为"把滑块拖到最下面却弹回最上面"。
const double hue = CurrentHue();
const std::array<double, 3> hueRgb = FromHsv(hue, 1.0, 1.0);
const std::uint32_t hueColor =
0xFF000000u | (ToByte(hueRgb[0]) << 16) | (ToByte(hueRgb[1]) << 8) | ToByte(hueRgb[2]);
const float hueMarkerY = m_hueRect.Y + static_cast<float>(hue) * m_hueRect.Height;
const float pillHeight = 10.0f * m_scale;
const float pillWidth = m_hueRect.Width + 9.0f * m_scale;
const gd::RectF pill(m_hueRect.X + (m_hueRect.Width - pillWidth) * 0.5f,
hueMarkerY - pillHeight * 0.5f, pillWidth, pillHeight);
UiFillRoundRect(g, pill, pillHeight * 0.5f, hueColor);
UiDrawRoundRect(g, pill, pillHeight * 0.5f, 0xFFFFFFFFu, 1.8f * m_scale);
}
for (int index = 0; index < 3; ++index)
{
const std::size_t slot = static_cast<std::size_t>(index);
const gd::RectF& track = m_tracks[slot];
// 通道标签放在滑块同一行的左侧,**右对齐**到"轨道左端 − 间隙":
// 这样 R/G/B 三个字母的右边界与轨道的距离严格等于轨道到数值区的距离
// (用户要求两侧留白相同,且三个字母纵向对齐)。
const gd::RectF labelRect = ChannelLabelRect(index);
UiText(g, kChannelNames[index], m_fonts->uiSmall, labelRect, m_theme->textMuted,
TextAlign::RIGHT);
const bool active = (m_draggingSlider == index) || (m_hoverSlider == index);
DrawChannelSlider(g, track, m_channels[slot], kChannelColors[index], active, m_scale);
if (m_editField == static_cast<EditField>(index + 1))
{
UiDrawEditField(g, PaddedEditRect(m_values[slot]), m_edit, m_fonts->monoSmall, m_scale);
}
else
{
UiText(g, std::to_wstring(ToByte(m_channels[slot])), m_fonts->monoSmall, m_values[slot],
m_theme->text, TextAlign::RIGHT);
}
}
// 预览与十六进制值。
const std::uint32_t color = CurrentColor();
UiFillRoundRect(g, m_previewRect, kSwatchCorner * m_scale, color);
UiDrawRoundRect(g, m_previewRect, kSwatchCorner * m_scale, m_theme->borderStrong, 1.0f);
if (m_editField == EditField::HEX)
{
UiDrawEditField(g, PaddedEditRect(m_hexRect), m_edit, m_fonts->monoSmall, m_scale);
}
else
{
wchar_t buffer[16];
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"#%02X%02X%02X", (color >> 16) & 0xFFu,
(color >> 8) & 0xFFu, color & 0xFFu);
// 与三行数值一样**右对齐**:色号的最后一位与数值的最后一位纵向对齐。
UiText(g, std::wstring(buffer), m_fonts->monoSmall, m_hexRect, m_theme->text,
TextAlign::RIGHT);
}
}
} // namespace jdi