Files
Just-DataInspector/Utilities/Src/ColorPickerPopup.cpp
T
DeepSeek V4.1 Flash 0871eb0cba 分组折起热区与改名输入框修正 + 数据模型去冗余(单一名称)+ 新增 Components/Shared 组件 + 过时注释/命名大清理 + 二进制瘦身 25.6%
## 一、UI 交互修复(用户报告)

① 分组折起热区(用户要求):点分组**名称**不再折起,只有**折叠三角**或**「组X」标签**
   才折起。RowPart 新增 GROUP_TITLE;RowHitPart() 为分组行判出三角 / 复选框 / 关闭 ✕ /
   「组X」标签矩形 / 其余 BODY;OnLButtonDown 只在这两个热区调用 ToggleGroupFromRow()。
   附带:双击「组X」不再进入改名(该区域现为折起热区),双击分组名仍可改名。
② 分组改名输入框不再吞掉「N 项」:RowNameRect() 对分组行的右边界改为「项数左边缘 − 6*scale」;
   新增 GroupLabelText()/GroupStatsText() 作为文本与宽度的唯一来源(PaintGroupRow 与
   RowNameRect 共用),避免名称可用宽度与实际绘制文本漂移。

## 二、数据模型去冗余(用户指出的重点)

- SignalData / SignalGroup **只保留一个名称字段** name:删除 displayName、删除 Title()
  (31 处调用点改为直接用 name);DataSet::SetSignalDisplayName / SetGroupDisplayName
  → RenameSignal / RenameGroup,且**空串 / 纯空白一律拒绝**(名称不再有回退值)。
  依据:信号靠 SignalRef 下标定位、从不按名字匹配 ⇒ 两个字段是同一语义的两份副本。
  收益:每条信号 / 每个分组少一个 std::wstring(24B + 堆分配)与一条 Title() 分支。
- 删除 Application::m_loadingBytes(全仓既不读也不写的死成员,注释却称“用于展示体积”)。

## 三、新增 Components/Shared 组件(通用 UI 控件)

- 从 Utilities/PaintUtil.* 迁出全部「控件类」绘制/命中:图标(+ / ✕ / 三角)、复选框、
  滑块(UiSliderRatioFromX / UiDrawSlider)、内联输入框(UiDrawEditField /
  UiDrawEditFieldContent / UiEditTextLeft / UiEditOffsetAt / UiEditFieldMouseDown /
  UiEditFieldMouseMove)、颜色样块(UiDrawSwatch / UiSwatchNeedsOutline)、
  取值胶囊配色(UiInk / UiColorLuminance / UiContrastInk)。
- PaintUtil.* 只保留低层绘制原语(矩形 / 圆角 / 直线 / 批量实线 / 颜色混合 / 文本与测量)。
- 依赖方向固定:Components/* → Components/Shared → Utilities(不得反向,Utilities 不再含控件)。
- Makefile 与 .preflight/perf/build.ps1 已登记新目录;调用方改 include "UiControls.hpp"。

## 四、过时注释 / 命名批量修正(承接上一轮未做完的部分)

- 注释:文件下标→分组下标、文件内信号下标→组内信号下标、所属文件下标→所属分组下标;
  措辞统一 文件分组→分组、移除该文件→移除该分组、打开 CSV 文件→打开数据文件 等一批。
- 文不对题的形参名:VisibleIndexRange(const TimeAxis& file,…) → axis;
  Application::LoadFileAsync(…, SignalGroup& file,…) → group。
- 函数名里的 File 语义(上一轮只改了 RowKind::FILE):PaintFileRow→PaintGroupRow、
  FileCloseRect→GroupCloseRect、OpenFileMenu→OpenGroupMenu、FileGroupLabelWidth→GroupLabelWidth、
  StyleEditField::FILE_NAME→GROUP_TITLE;局部变量 fileRow/fileHeight/isFile →
  groupRow/groupHeight/isGroup。
- 修复 DataModel.hpp 两处被压扁的注释块(/**<TAB><TAB>* @brief …)与 Application::NewTab
  上方重复的文档块。
- ⚠️ 踩坑:GROUP_NAME 撞上 nb30.h(windows.h 引入)的 #define GROUP_NAME 0x80,
  导致枚举编译失败,改名 GROUP_TITLE 解决。

## 五、二进制瘦身:1 013 248 → 754 176 B(−25.6%,省 259 KB)

- 真因不是 PlotView.cpp,而是 Resources/JustDataInspector.ico 里 **256×256@32bpp 以未压缩
  BMP 存储的条目(270 376 B,占整包 27%)**。改为 PNG 存储(Vista+ 标准做法,观感零变化)
  ⇒ 该条目 270 376 → 11 280 B、整个 ico 302 430 → 43 334 B(小尺寸仍保留 BMP)。
- 实测结论:“拆分大文件 ⇒ 瘦身”不成立(同一批机器码仍要进二进制);
  -ffunction-sections/-fdata-sections/-Wl,--gc-sections 在本工程反而 +9 216 B(已回退)。
- ⚠️ .o 体积受节对齐/重定位表主导,判断“代码搬去哪了”要用 nm,不要看 .o 字节数。

## 六、验证(全部离屏:SW_HIDE + 不注入真实输入 + 进程/线程 BELOW_NORMAL)

- panelanim 156/156(连跑 5 次稳定;新增/更新:点分组名不折起也不滚动、扫掠定位「组X」热区、
  筛选态与普通态折叠三角逐像素一致、改名输入框右边界早于项数文本);verify 49/49;
  datacheck 69/69(含“重命名写入唯一名称字段且空名被拒”);edgecheck 46/46。
- A/B:回退两处 UI 修复 ⇒ 10 项如实失败;恢复后全绿。
- make release / make debug 零警告。

## 七、本轮未完成(已在报告与 agent_mem.md 中如实记录)

- PlotView.cpp(13 695 行 / 544 KB)按功能拆分为多个协作文件:未执行。
  前置改造是把第 13–888 行的匿名命名空间(~870 行常量与工具函数)改成具名命名空间并逐个
  inline,再逐个文件搬移并反复验证;时间不足以安全完成,故主动不做(附可执行方案)。
- SignalPanel.cpp / Application.cpp / DataModel.cpp 的多文件化、Lane 抽出 LanePanelState、
  SetStatusMessage 模态阻塞解耦:均未执行。

## 八、文档

- 新增 Documents/refactor_report_2026-10-16.md(本轮详细报告);
  删除 Documents/refactor_report_2026-10-15.md(有效内容已并入 agent_mem.md 与本报告)。
- Documents/agent_mem.md 新增 §9.37 与 §11.2 第 85–90 条;更新数据层架构摘要(单一名称字段);
  §12 历史附录加“时效声明”。
2026-10-16 03:25:48 +08:00

1097 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 "UiControls.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