From 40b12d6e8b7abd50f63516933d1f3bc8bd59a8af Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Sat, 10 Oct 2026 14:02:37 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E8=89=B2=E6=80=A7=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20+=20=E6=96=B0=E5=A2=9E=E8=89=B2=E5=9F=9F=E6=96=B9?= =?UTF-8?q?=E5=9D=97=E6=8B=BE=E8=89=B2=20+=20=E5=8F=96=E5=80=BC=E8=83=B6?= =?UTF-8?q?=E5=9B=8A=E9=AB=98=E5=AF=B9=E6=AF=94=E9=85=8D=E8=89=B2=20+=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=82=B9=E6=A0=87=E8=AE=B0=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复自定义颜色滑动卡顿(VERY_BIG_DATA 全信号可见时 36ms/步 → 约 12ms/步) - 根因:场景缓存的内容指纹把逐信号的颜色/不透明度/线宽/模式都算了进去, 改一次颜色就整幅失效,于是所有网格的波形都要重新光栅化。 - 新增"不含外观样式"的几何指纹与样式快照:只有在网格结构、视图几何、信号集合与 文字都不变、仅外观样式变化时,走"局部重画"快路径 —— 只重画颜色变了的那个网格, 再把结果写回场景缓存(下一帧回到纯贴图)。 - 补丁矩形向内对齐到整数像素,避免把属于相邻网格的一行擦掉而留下 1px 接缝; 底色填充按补丁矩形本身进行(大面积不透明填充会走 GDI 快路径,不受 GDI+ 裁剪约束)。 - 该优化对调色板点击、线宽/不透明度滑块、绘制模式切换同样生效。 2. 自定义颜色新增"方块色域图"拾色 - 新增饱和度-明度方块(横轴饱和度、纵轴明度)与纵向色相条,可直接在色域图上取色。 - 与面板内其它控件完全联动:RGB 滑块、十六进制输入、预览色块同源(RGB 为唯一数据源), 并记忆色相,消色差时仍可用色相条换回颜色。 - 色域方块按当前色相预渲染缓存,色相不变时不重复绘制;色域内用十字光标、色相条用纵向光标。 3. 取值胶囊的对比度描边与文本色改为对信号配色线性响应 - 原实现只有一个亮度阈值,文字/描边只有黑、白两套固定取值。现按底色亮度选定亮/暗一侧, 再与底色的互补色按固定比例线性混合(互补色亮度恰为 1−底色亮度), 因此文字与描边的亮度随信号配色线性变化,且任意底色下都保持高对比。 4. 修复光标数据点越界三角的判定与绘制 - 是否改用三角,以"数据点本身是否超出绘制边界"为准,不再因为数值标注被贴边而改变形状。 - 三角方向修正为尖端朝外(此前上越界画下三角、左越界画右三角,方向完全相反), 内三角改按质心收缩,描边不再是歪的;上下越界时三角与显示的数值纵向居中对齐。 --- Components/Application/Src/Application.cpp | 4 + Components/PlotView/Inc/PlotView.hpp | 69 +++- Components/PlotView/Src/PlotView.cpp | 440 ++++++++++++++++----- Utilities/Inc/ColorPickerPopup.hpp | 58 ++- Utilities/Inc/PaintUtil.hpp | 34 ++ Utilities/Src/ColorPickerPopup.cpp | 365 ++++++++++++++++- Utilities/Src/PaintUtil.cpp | 31 ++ 7 files changed, 877 insertions(+), 124 deletions(-) diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 76d4e55..7bdfc77 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -348,6 +348,10 @@ namespace jdi ReleaseBackBuffer(); m_fonts.Destroy(); + // 拾色器缓存了 GDI+ 位图(色域方块):必须在 GdiplusShutdown 之前释放, + // 否则位图析构会发生在 GDI+ 关闭之后,直接访问违例。 + m_colorPicker.Close(); + if (m_gdiplusReady) { gd::GdiplusShutdown(m_gdiplusToken); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 536bc29..ffa556f 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -835,12 +835,13 @@ namespace jdi bool IsOnCursorSpanLabel(int x, int y) const; /** - * @brief 画文字时给底色胶囊加一层与文字反色的细描边,提升与背景/曲线的对比度。 + * @brief 画文字时给底色胶囊加一层细描边,提升与背景/曲线的对比度。 * @param [IN] rect 胶囊矩形。 * @param [IN] radius 圆角半径。 - * @param [IN] textDark 文字是否用深色。 + * @param [IN] outlineColor 描边色(由 `LabelInkFor` 与文字色一并算出的反相色)。 */ - void DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, bool textDark); + void DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, + std::uint32_t outlineColor); /** * @brief 精确检测鼠标位置是否命中某条可见信号曲线。 @@ -1404,9 +1405,65 @@ namespace jdi * 视图几何以外的任何内容变化(改颜色/线宽/透明度/样式、重命名、增删信号) * 都会改变该指纹,从而让静态场景缓存自动失效,无需在各处手工调用失效接口。 * + * @param [IN] includeStyles 是否把逐信号外观样式(颜色/不透明度/线宽/模式)计入指纹。 * @return 指纹值。 */ - std::uint64_t SceneContentHash() const; + std::uint64_t SceneContentHash(bool includeStyles = true) const; + + /** + * @brief 场景内容中**不含逐信号外观样式**的指纹。 + * + * 只包含网格结构、视图几何、网格幅值、信号集合与可见性、图例文案、选中项等 + * "变化后必须整幅重建"的内容;逐信号的 `color / opacity / lineWidth / mode` + * 一律不参与。 + * + * 用途:外观样式变化时(典型场景是拖动自定义颜色的滑块)波形几何完全不变, + * 只要该指纹一致,就可以只重画"颜色变了的那个网格"再写回缓存, + * 而不必整幅重绘全部网格(VERY_BIG_DATA 实测整幅 30ms+、单网格约 4ms)。 + * + * @return 指纹值。 + */ + std::uint64_t SceneGeometryHash() const; + + /** + * @brief 静态场景缓存中"网格内信号外观样式"的快照条目。 + */ + struct SceneStyleEntry + { + std::uint32_t color = 0; /**< 信号色(未叠加不透明度)。 */ + float opacity = 1.0f; /**< 不透明度。 */ + float lineWidth = 1.0f; /**< 线宽。 */ + int mode = 0; /**< 绘制模式。 */ + }; + + /** + * @brief 采集当前各网格内可见信号的外观样式快照。 + * @param [OUT] out 采集结果(按网格分组)。 + */ + void CollectSceneStyles(std::vector>& out) const; + + /** + * @brief 判断当前相对缓存是否"只有外观样式发生了变化"。 + * + * 成立条件:`SceneGeometryHash()` 与缓存一致(网格结构、视图几何、信号集合 + * 与文字都没变),且至少有一个网格内信号的外观样式不同。 + * + * @param [OUT] lanes 外观发生变化的网格下标(升序)。 + * @return 可以走"局部重画"快路径返回 true。 + */ + bool FindStyleOnlyLanes(std::vector& lanes) const; + + /** + * @brief 只重画指定网格并把结果写回静态场景缓存。 + * + * 必须在 `BlitSceneCache()` 之后调用:贴图已经完成,这里把受影响网格的 + * "底色 + 网格线 + 波形 + 标尺 + 图例"重画到画布上,再整块拷回缓存位图, + * 使缓存内容与当前样式一致(下一帧即可重新走"完全命中"快路径)。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] lanes 需要重画的网格下标。 + */ + void RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes); /** * @brief 当前应当绘入静态场景缓存的信号光晕(仅拖拽中的信号)。 @@ -1485,6 +1542,10 @@ namespace jdi int m_sceneY = 0; /**< 缓存对应的客户区纵坐标(绘图区上边界)。 */ bool m_sceneValid = false; /**< 缓存内容是否有效。 */ SceneCacheKey m_sceneKey; /**< 缓存对应的场景参数。 */ + /** 缓存捕获时"不含逐信号外观样式"的内容指纹(用于识别仅样式变化)。 */ + std::uint64_t m_sceneGeometryHash = 0; + /** 缓存捕获时各网格内信号的外观样式快照(按网格分组,与 m_sceneStyles 同序)。 */ + std::vector> m_sceneStyles; /** * @brief 查询坐标命中的图例条目。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 13fd72e..92cc0f0 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -29,6 +29,8 @@ namespace jdi const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */ const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */ const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */ + const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */ + const float kMarkerInsideMargin = 2.0f; /**< 数据点在视界内时允许贴到边界的最小余量。 */ const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */ const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */ @@ -407,19 +409,6 @@ namespace jdi return (argb & 0x00FFFFFFu) | (a << 24); } - /** - * @brief 判断颜色是否偏暗(用于在信号色底板上选择可读的文字颜色)。 - * @param [IN] color 0xAARRGGBB 颜色值。 - * @return 相对亮度低于阈值返回 true。 - */ - bool IsDarkColor(std::uint32_t color) - { - const double r = static_cast((color >> 16) & 0xFFu); - const double g = static_cast((color >> 8) & 0xFFu); - const double b = static_cast(color & 0xFFu); - return (0.299 * r + 0.587 * g + 0.114 * b) < 140.0; - } - /** * @brief 将数值限制到合法区间。 * @param [IN] value 输入值。 @@ -4234,12 +4223,9 @@ namespace jdi return; } - // 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变, - // "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。 - // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 - // "只改一小块叠加层"的交互因此不再重绘波形。 - // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 - if (SceneCacheMatches() && BlitSceneCache(g)) + // 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。 + // 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。 + const auto paintOverlays = [this, &g]() { PaintHoverHighlight(g); PaintLegendHighlight(g); @@ -4248,6 +4234,7 @@ namespace jdi PaintEdgeCursor(g, m_lanes); PaintSignalInteraction(g); PaintHint(g); + // 网格 Y 轴上下限的输入框绘制在最上层。 if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN) { @@ -4258,9 +4245,32 @@ namespace jdi // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f); g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); + }; + + // 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变, + // "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。 + // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 + // "只改一小块叠加层"的交互因此不再重绘波形。 + // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 + if (SceneCacheMatches() && BlitSceneCache(g)) + { + paintOverlays(); return; } + // 只有"外观样式"变化(典型场景:拖动自定义颜色滑块实时调色):网格结构、视图几何、 + // 波形采样都没变,只是颜色/不透明度/线宽/模式不同 —— 此时只重画受影响的网格, + // 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。) + { + std::vector patchLanes; + if (FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g)) + { + RepaintSceneCachePatch(g, patchLanes); + paintOverlays(); + return; + } + } + const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); const double span = viewMax - viewMin; @@ -4288,25 +4298,7 @@ namespace jdi // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 CaptureSceneCache(g); - PaintHoverHighlight(g); - PaintLegendHighlight(g); - PaintRubberBand(g); - PaintCursor(g, m_lanes); - PaintEdgeCursor(g, m_lanes); - PaintSignalInteraction(g); - PaintHint(g); - - // 网格 Y 轴上下限的输入框绘制在最上层。 - if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN) - { - PaintEditField(g); - } - - PaintScrollBar(g); - - // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 - gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f); - g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); + paintOverlays(); } void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, @@ -4479,11 +4471,13 @@ namespace jdi return SignalRef(); } - std::uint64_t PlotView::SceneContentHash() const + std::uint64_t PlotView::SceneContentHash(bool includeStyles) const { // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 + // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), + // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { @@ -4547,10 +4541,16 @@ namespace jdi { continue; } - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->lineWidth); - mix(static_cast(resolved.style->mode)); + // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 + // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, + // 于是可以只重画颜色变了的网格,而不必整幅重绘。) + if (includeStyles) + { + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->lineWidth); + mix(static_cast(resolved.style->mode)); + } // 图例文案参与绘制,重命名必须让缓存失效。 mixText(resolved.data->Title()); if (resolved.file != nullptr) @@ -4562,6 +4562,190 @@ namespace jdi return hash; } + std::uint64_t PlotView::SceneGeometryHash() const + { + return SceneContentHash(false); + } + + void PlotView::CollectSceneStyles(std::vector>& out) const + { + out.clear(); + out.resize(m_lanes.size()); + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const std::vector& signals = Tab().Lanes()[laneIndex].signals; + std::vector& lane = out[laneIndex]; + lane.reserve(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + SceneStyleEntry entry; + if (resolved.style != nullptr) + { + entry.color = resolved.style->color; + entry.opacity = resolved.style->opacity; + entry.lineWidth = resolved.style->lineWidth; + entry.mode = static_cast(resolved.style->mode); + } + lane.push_back(entry); + } + } + } + + bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const + { + lanes.clear(); + if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) + { + // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 + return false; + } + + std::vector> current; + CollectSceneStyles(current); + if (current.size() != m_sceneStyles.size()) + { + return false; + } + + for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) + { + const std::vector& now = current[laneIndex]; + const std::vector& before = m_sceneStyles[laneIndex]; + if (now.size() != before.size()) + { + return false; + } + bool differs = false; + for (std::size_t index = 0; index < now.size() && !differs; ++index) + { + differs = (now[index].color != before[index].color) || + (now[index].opacity != before[index].opacity) || + (now[index].lineWidth != before[index].lineWidth) || + (now[index].mode != before[index].mode); + } + if (differs) + { + lanes.push_back(static_cast(laneIndex)); + } + } + return !lanes.empty(); + } + + void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) + { + if (lanes.empty() || m_sceneDc == nullptr) + { + return; + } + + // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 + float top = 1.0e9f; + float bottom = -1.0e9f; + for (const int laneIndex : lanes) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + continue; + } + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + top = std::min(top, rect.Y); + bottom = std::max(bottom, rect.Y + rect.Height); + } + if (bottom <= top) + { + return; + } + top = std::max(top, m_plotRect.Y); + bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); + if (bottom <= top) + { + return; + } + + // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): + // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, + // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), + // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 + // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 + const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); + const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); + const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), + static_cast(std::floor(m_area.X + m_area.Width))); + const int y1 = std::min(static_cast(std::floor(bottom)), + static_cast(std::floor(m_area.Y + m_area.Height))); + if (x1 <= x0 || y1 <= y0) + { + return; + } + + const gd::RectF patch(static_cast(x0), static_cast(y0), + static_cast(x1 - x0), static_cast(y1 - y0)); + + // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 + // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 + std::vector repaint; + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const gd::RectF& rect = m_lanes[laneIndex].rect; + if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) + { + repaint.push_back(static_cast(laneIndex)); + } + } + if (repaint.empty()) + { + return; + } + + // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 + { + const int saveState = g.Save(); + g.SetClip(patch, gd::CombineModeIntersect); + // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: + // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 + // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 + UiFillRect(g, patch, m_theme->plotBg); + const float cardTop = std::max(patch.Y, m_plotRect.Y); + const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); + if (cardBottom > cardTop) + { + UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), + m_theme->cardBg); + } + + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + const double span = viewMax - viewMin; + const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); + std::vector timeTicks; + BuildTicks(viewMin, viewMax, timeStep, timeTicks); + + for (const int laneIndex : repaint) + { + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) + { + PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); + } + } + g.Restore(saveState); + } + + // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 + { + HDC target = g.GetHDC(); + if (target != nullptr) + { + BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); + g.ReleaseHDC(target); + } + } + + m_sceneKey = MakeSceneCacheKey(); + m_sceneGeometryHash = SceneGeometryHash(); + CollectSceneStyles(m_sceneStyles); + } + bool PlotView::EnsureSceneCache(int width, int height) { if (width <= 0 || height <= 0) @@ -4638,6 +4822,8 @@ namespace jdi } m_sceneKey = MakeSceneCacheKey(); + m_sceneGeometryHash = SceneGeometryHash(); + CollectSceneStyles(m_sceneStyles); m_sceneValid = true; return true; } @@ -4663,6 +4849,8 @@ namespace jdi void PlotView::InvalidateSceneCache() { m_sceneValid = false; + m_sceneStyles.clear(); + m_sceneGeometryHash = 0; } void PlotView::ReleaseSceneCache() @@ -4793,7 +4981,11 @@ namespace jdi } const int saveState = g.Save(); - g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop)); + // 与当前裁剪区求交(而不是替换):局部重画(RepaintSceneCachePatch)时 + // 外层已经按“需要重画的网格范围”设过裁剪区,这里必须保留,否则会重画到 + // 其他网格上。整幅重绘时外层没有裁剪区,等同于原来的行为。 + g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop), + gd::CombineModeIntersect); // 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。 @@ -5383,12 +5575,12 @@ namespace jdi } - void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, bool textDark) + void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, + std::uint32_t outlineColor) { - // 用与文字同色的半透明细描边:深色文字 → 浅色描边,浅色文字 → 深色描边。 + // 描边色由 `UiContrastInk` 与文字色一起算出(恒与文字色反相、且随信号配色线性变化)。 // 这样胶囊压在同类色背景或信号曲线上时仍有清晰的边界。 - const std::uint32_t outline = textDark ? 0x99FDF6E3u : 0x99073642u; - UiDrawRoundRect(g, rect, radius, outline, 1.0f); + UiDrawRoundRect(g, rect, radius, outlineColor, 1.0f); } gd::RectF PlotView::CursorSpanRect(bool& insideAxis) const @@ -5549,16 +5741,18 @@ namespace jdi const float rawY = ValueToY(value, lane); // 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。 // 上边界额外预留光标顶部把手的高度,避免数据点三角压住光标把手。 - const DataMarker marker = + // 非 const:上下越界时下面还要把方向三角与取值标注纵向居中对齐。 + DataMarker marker = MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); const std::uint32_t signalColor = StyleColor(*resolved.style); // 数据点:视界内为圆点,任一方向越界时改为贴边三角。 + // **先算好取值标注、再画数据点**:上下越界时的方向三角需要与标注 + // 纵向居中对齐,而标注的位置要经过避让处理才能最终确定。 const float radius = 3.4f * m_scale; - PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg); - // 取值标注:底色用信号色、文字用反色,并加一层细描边提升对比度。 + // 取值标注:底色用信号色,文字/描边用随信号配色线性相关的高对比度配色。 std::wstring text = FormatValue(value); if (showDelta) { @@ -5593,11 +5787,18 @@ namespace jdi // 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。 PlaceCursorLabel(labelRect, labelTop, textHeight, clipTop, clipBottom); + // 上下越界的方向三角与最终落位的取值标注纵向居中对齐 + // (用户明确要求“超过上下视界时三角需要与显示的数值居中对齐”)。 + if (marker.offUp || marker.offDown) + { + marker.y = labelRect.Y + labelRect.Height * 0.5f; + } + PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg); + + const UiInk ink = UiContrastInk(signalColor); UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0)); - const bool dark = IsDarkColor(signalColor); - DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark); - UiText(g, text, m_fonts->monoSmall, labelRect, - dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); + UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); // 「边沿计数(中心轴)」/「均值滤波」开启时,纵向光标还要额外标注**派生曲线** // 在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 @@ -5617,12 +5818,11 @@ namespace jdi } const float rawCenterY = ValueToY(derivedValue, lane); - const DataMarker centerMarker = MakeDataMarker( + DataMarker centerMarker = MakeDataMarker( rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); // 派生数据点的配色与原始信号一致(便于一一对应); // 但**始终按线段模式**绘制(不跟随原信号的散点模式)。 const std::uint32_t centerColor = StyleColor(*resolved.style); - PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg); // 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标 // 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。 @@ -5665,12 +5865,18 @@ namespace jdi std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale)); gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight); PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom); + // 与原始信号同一规则:上下越界的方向三角与取值标注纵向居中对齐。 + if (centerMarker.offUp || centerMarker.offDown) + { + centerMarker.y = centerRect.Y + centerRect.Height * 0.5f; + } + PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg); // 颜色样式与原始信号一致(同底色/同描边/同文字配色)。 - const bool centerDark = IsDarkColor(centerColor); + const UiInk centerInk = UiContrastInk(centerColor); UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0)); - DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerDark); - UiText(g, centerText, m_fonts->monoSmall, centerRect, - centerDark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerInk.outline); + UiText(g, centerText, m_fonts->monoSmall, centerRect, centerInk.text, + TextAlign::CENTER); }; if (m_tab->Lanes()[laneIndex].edgeCenter) @@ -5705,11 +5911,10 @@ namespace jdi const std::uint32_t color = CursorColor(); UiFillRoundRect(g, labelRect, 3.0f * m_scale, highlighted ? color : WithAlpha(color, 0xE0)); - const bool dark = IsDarkColor(color); - DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark); + const UiInk ink = UiContrastInk(color); + DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); UiText(g, FormatCursorTime(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()), - m_fonts->monoSmall, labelRect, - dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); } // 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。 @@ -5724,10 +5929,10 @@ namespace jdi const std::uint32_t color = CursorColor(); UiFillRoundRect(g, spanRect, 3.0f * m_scale, m_spanHover ? color : WithAlpha(color, 0xE0)); - const bool dark = IsDarkColor(color); - DrawLabelOutline(g, spanRect, 3.0f * m_scale, dark); - UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, - dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + const UiInk ink = UiContrastInk(color); + DrawLabelOutline(g, spanRect, 3.0f * m_scale, ink.outline); + UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, ink.text, + TextAlign::CENTER); } } else @@ -5868,15 +6073,33 @@ namespace jdi float clipBottom, float topReserve) const { DataMarker marker; - // 上下越界时"钉"在网格可见边缘(留出三角形的高度,保证整体可见); - // 另外预留 topReserve(时间光标的顶部三角把手),避免数据点三角压住把手。 - const float margin = 7.0f * m_scale; + // 形状**只**由“数据点本身是否越出绘制边界”决定: + // 旧实现拿“夹取之后”的位置反推越界方向,于是贴近边界、其实仍在视界内的数据点 + // 也会被误画成三角(用户报告的“数值显示超出边界就改变数据点形状”)。 + const float left = m_plotRect.X; + const float right = m_plotRect.X + m_plotRect.Width; + marker.offLeft = (rawX < left); + marker.offRight = (rawX > right); + marker.offUp = (rawY < clipTop); + marker.offDown = (rawY > clipBottom); + marker.x = pinX; - marker.y = std::max(clipTop + margin + topReserve, std::min(rawY, clipBottom - margin)); - marker.offLeft = (rawX < marker.x - 0.5f); - marker.offRight = (rawX > marker.x + 0.5f); - marker.offUp = (rawY < marker.y - 0.5f); - marker.offDown = (rawY > marker.y + 0.5f); + if (marker.offUp) + { + // 上越界:为方向三角与顶部把手预留高度,避免压住把手。 + marker.y = clipTop + kMarkerEdgeInset * m_scale + topReserve; + } + else if (marker.offDown) + { + marker.y = clipBottom - kMarkerEdgeInset * m_scale; + } + else + { + // 视界内:保留数据点的真实纵坐标(越界判定已在上面完成,这里夹取只为了让 + // 圆点整体可见,不会影响形状)。 + marker.y = std::max(clipTop + kMarkerInsideMargin * m_scale, + std::min(rawY, clipBottom - kMarkerInsideMargin * m_scale)); + } return marker; } @@ -5885,44 +6108,56 @@ namespace jdi { gd::SolidBrush ring(ToGdiColor(ringColor)); gd::SolidBrush fill(ToGdiColor(fillColor)); - const float arm = 5.0f * m_scale; - const float inset = 1.4f * m_scale; + const float arm = 5.0f * m_scale; /**< 三角“半宽/半高”(垂直于朝向的半边长)。 */ + const float depth = arm * 1.4f; /**< 三角沿朝向的深度(与光标把手同比例)。 */ - // 左右越界:三角底边在外侧、尖端朝向网格内部。 + // 越界三角:**尖端朝外**(指向数据点所在的方向,即视界之外),底边在网格内侧; + // 三角外接矩形在横/纵两个方向都与锚点**居中**(上下越界时因此与取值标注纵向居中)。 + // 旧实现方向是反的(上越界画下三角、左越界画右三角),而且内三角按“平移”而非 + // “向质心收缩”构造,填充色会从三角尖端戳出来。 + const auto paintTriangle = [&](const gd::PointF* outer) + { + g.FillPolygon(&ring, outer, 3); + const float centerX = (outer[0].X + outer[1].X + outer[2].X) / 3.0f; + const float centerY = (outer[0].Y + outer[1].Y + outer[2].Y) / 3.0f; + // 按质心等比收缩得到内三角:几何上恒在外三角内部,描边宽度也就均匀了。 + const float shrink = 0.58f; + gd::PointF inner[3]; + for (int index = 0; index < 3; ++index) + { + inner[index] = gd::PointF(centerX + (outer[index].X - centerX) * shrink, + centerY + (outer[index].Y - centerY) * shrink); + } + g.FillPolygon(&fill, inner, 3); + }; + + // 左右越界:三角指向左/右(视界之外)。 if (marker.offLeft || marker.offRight) { const bool offLeft = marker.offLeft; - const float baseX = offLeft ? (marker.x - arm) : (marker.x + arm); - const float inward = offLeft ? inset : -inset; + const float tipX = offLeft ? (marker.x - depth) : (marker.x + depth); + const float baseX = marker.x; gd::PointF outer[3]; outer[0] = gd::PointF(baseX, marker.y - arm); outer[1] = gd::PointF(baseX, marker.y + arm); - outer[2] = gd::PointF(marker.x, marker.y); - g.FillPolygon(&ring, outer, 3); - gd::PointF inner[3]; - inner[0] = gd::PointF(baseX + inward, marker.y - arm * 0.72f); - inner[1] = gd::PointF(baseX + inward, marker.y + arm * 0.72f); - inner[2] = gd::PointF(marker.x + inward, marker.y); - g.FillPolygon(&fill, inner, 3); + outer[2] = gd::PointF(tipX, marker.y); + paintTriangle(outer); return; } - // 上下越界:三角底边在上/下方、尖端朝向网格内部(与左右越界同一套观感)。 + // 上下越界:三角指向上/下(视界之外),外接矩形以 marker.y 为纵向中心 + // → 与取值标注的纵向中心对齐。 if (marker.offUp || marker.offDown) { const bool offUp = marker.offUp; - const float baseY = offUp ? (marker.y - arm) : (marker.y + arm); - const float inward = offUp ? inset : -inset; + const float half = depth * 0.5f; + const float tipY = offUp ? (marker.y - half) : (marker.y + half); + const float baseY = offUp ? (marker.y + half) : (marker.y - half); gd::PointF outer[3]; outer[0] = gd::PointF(marker.x - arm, baseY); outer[1] = gd::PointF(marker.x + arm, baseY); - outer[2] = gd::PointF(marker.x, marker.y); - g.FillPolygon(&ring, outer, 3); - gd::PointF inner[3]; - inner[0] = gd::PointF(marker.x - arm * 0.72f, baseY + inward); - inner[1] = gd::PointF(marker.x + arm * 0.72f, baseY + inward); - inner[2] = gd::PointF(marker.x, marker.y + inward); - g.FillPolygon(&fill, inner, 3); + outer[2] = gd::PointF(marker.x, tipY); + paintTriangle(outer); return; } @@ -6115,7 +6350,7 @@ namespace jdi std::vector& tabLanes = m_tab->Lanes(); const std::size_t count = std::min(lanes.size(), tabLanes.size()); const std::uint32_t color = CursorColor(); - const bool dark = IsDarkColor(color); + const UiInk ink = UiContrastInk(color); // ---- 横向光标(线与两端把手 + 左侧幅值读数)---- for (std::size_t index = 0; index < count; ++index) @@ -6172,9 +6407,8 @@ namespace jdi clipBottom - textHeight - 1.0f * m_scale)); const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight); UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(color, 0xE0)); - DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark); - UiText(g, text, m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u, - TextAlign::CENTER); + DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); + UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); } // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- diff --git a/Utilities/Inc/ColorPickerPopup.hpp b/Utilities/Inc/ColorPickerPopup.hpp index 7f72978..c2587a5 100644 --- a/Utilities/Inc/ColorPickerPopup.hpp +++ b/Utilities/Inc/ColorPickerPopup.hpp @@ -7,6 +7,7 @@ #include #include +#include #include "PaintUtil.hpp" #include "Theme.hpp" @@ -180,6 +181,17 @@ namespace jdi HEX = 4 /**< 十六进制色号。 */ }; + /** + * @brief 面板内正在拖拽的控件。 + */ + enum class DragTarget : int + { + NONE = 0, /**< 未拖拽。 */ + SLIDER = 1, /**< RGB 分量滑块(具体下标见 `m_draggingSlider`)。 */ + GAMUT = 2, /**< 色域方块(饱和度-明度平面)。 */ + HUE = 3 /**< 色相条。 */ + }; + /** * @brief 依据锚点矩形与客户区计算面板矩形与内部控件矩形。 * @param [IN] bounds 宿主客户区矩形。 @@ -222,6 +234,41 @@ namespace jdi */ 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 RefreshHueFromChannels(); + + /** + * @brief 销毁色域方块缓存位图(尺寸或色相变化时重建)。 + */ + void InvalidateGamutCache(); + + /** + * @brief 确保色域方块缓存与当前色相/缩放匹配(必要时重建)。 + * @return 可用返回 true。 + */ + bool EnsureGamutCache(); + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ HWND m_owner = nullptr; /**< 剪贴板所有者窗口句柄。 */ @@ -231,7 +278,9 @@ namespace jdi bool m_changed = false; /**< 取值是否发生变化。 */ int m_draggingSlider = -1; /**< 正在拖拽的滑块下标,-1 表示未拖拽。 */ int m_hoverSlider = -1; /**< 当前悬停的滑块下标。 */ - std::array m_channels = {0.0, 0.0, 0.0}; /**< RGB 分量(0-1)。 */ + DragTarget m_drag = DragTarget::NONE; /**< 正在拖拽的控件。 */ + std::array m_channels = {0.0, 0.0, 0.0}; /**< RGB 分量(0-1,唯一数据源)。 */ + double m_hue = 0.0; /**< 记忆色相(0-1);灰度/黑色时保留上次取值。 */ gd::RectF m_rect; /**< 面板整体矩形。 */ gd::RectF m_titleRect; /**< 标题区域。 */ @@ -239,6 +288,13 @@ namespace jdi std::array m_values; /**< 数值文本矩形。 */ gd::RectF m_previewRect; /**< 预览色块矩形。 */ gd::RectF m_hexRect; /**< 十六进制文本矩形。 */ + gd::RectF m_gamutRect; /**< 色域方块矩形(横轴 S,纵轴 V)。 */ + gd::RectF m_hueRect; /**< 色相条矩形(纵轴色相)。 */ + + /** 色域方块缓存位图:按当前色相预渲染,避免每帧重画数十条渐变。 */ + std::unique_ptr m_gamutCache; + double m_gamutCacheHue = -1.0; /**< 缓存对应的色相。 */ + int m_gamutCacheSize = 0; /**< 缓存位图边长(像素)。 */ EditField m_editField = EditField::NONE; /**< 当前处于输入状态的字段。 */ TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 7c577d8..6182bc4 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -293,6 +293,40 @@ namespace jdi void UiDrawEditFieldContent(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale); + /** + * @brief 取值胶囊底色上的高对比度文字色与描边色。 + */ + struct UiInk + { + std::uint32_t text = 0xFF073642u; /**< 文字色(不透明)。 */ + std::uint32_t outline = 0x99FDF6E3u; /**< 描边色(半透明,恒与文字色反相)。 */ + }; + + /** + * @brief 颜色的相对亮度(0-1,逐通道线性加权)。 + * @param [IN] color 0xAARRGGBB 颜色值。 + * @return 相对亮度。 + */ + double UiColorLuminance(std::uint32_t color); + + /** + * @brief 依据胶囊底色求高对比度的文字色与描边色(对底色配色作线性响应)。 + * + * 求值分两步: + * 1. **亮度**:按"取最大对比"的一侧选定亮文字或暗文字。这是唯一的阈值判断,但它 + * 本身就是"任意底色下都保持高对比"的必要条件 —— 任何连续的亮度取值都会在中性灰 + * 附近同时远离两侧极值,对比度必然塌陷。 + * 2. **配色**:把该侧的极值色与**底色的互补色**按固定比例线性混合。互补色的相对亮度 + * 恰为 `1 - 底色亮度`,因此文字/描边的亮度是底色亮度的**线性**函数,色相也随信号 + * 配色连续变化(不再是固定的黑/白两色)。 + * + * 描边色取相反的一侧,保证"描边—文字—底色"三段式对比。 + * + * @param [IN] background 胶囊底色(0xAARRGGBB)。 + * @return 文字色与描边色。 + */ + UiInk UiContrastInk(std::uint32_t background); + /** * @brief 绘制一个带边框的复选框。 * @param [IN,OUT] g 绘图上下文。 diff --git a/Utilities/Src/ColorPickerPopup.cpp b/Utilities/Src/ColorPickerPopup.cpp index 75384c9..a5f2494 100644 --- a/Utilities/Src/ColorPickerPopup.cpp +++ b/Utilities/Src/ColorPickerPopup.cpp @@ -11,11 +11,11 @@ namespace jdi namespace { - const float kPanelWidth = 214.0f; /**< 面板宽度(逻辑像素)。 */ + 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 kTitleGap = 8.0f; /**< 标题与内容区间距。 */ const float kRowHeight = 26.0f; /**< 单个滑块行高度。 */ const float kRowGap = 2.0f; /**< 滑块行间距。 */ const float kPreviewGap = 10.0f; /**< 滑块区与预览区间距。 */ @@ -25,6 +25,11 @@ namespace jdi const float kValueWidth = 40.0f; /**< 数值文本宽度。 */ const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ const float kCorner = 6.0f; /**< 面板圆角半径。 */ + const float kGamutSize = 112.0f; /**< 色域方块边长。 */ + const float kHueWidth = 14.0f; /**< 色相条宽度。 */ + const float kGamutGap = 10.0f; /**< 色域方块与色相条间距。 */ + const float kColumnGap = 12.0f; /**< 色相条与滑块列间距。 */ + const float kGamutBorder = 1.0f; /**< 色域方块/色相条描边宽度。 */ /** @brief 通道显示名称。 */ const wchar_t* kChannelNames[3] = {L"R", L"G", L"B"}; @@ -32,6 +37,116 @@ namespace jdi /** @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& 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 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(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 绘图上下文。 @@ -102,9 +217,11 @@ namespace jdi void ColorPickerPopup::BuildLayout(const gd::RectF& bounds, const gd::RectF& anchor) { const float width = kPanelWidth * m_scale; - const float height = (kPadV * 2.0f + kTitleHeight + kTitleGap + kRowHeight * 3.0f + kRowGap * 2.0f + - kPreviewGap + kPreviewHeight) * - m_scale; + // 高度:标题 + 内容区。内容区里"色域方块"是最高的控件, + // 预览行/色号行落在它与滑块列的下方,仍在其纵向范围内,不再额外叠加高度。 + const float contentHeight = std::max(kGamutSize, kRowHeight * 3.0f + kRowGap * 2.0f + + kPreviewGap + kPreviewHeight); + const float height = (kPadV * 2.0f + kTitleHeight + kTitleGap + contentHeight) * m_scale; const float gap = 4.0f * m_scale; @@ -129,14 +246,26 @@ namespace jdi m_titleRect = gd::RectF(left + kPadH * m_scale, top + kPadV * m_scale, width - kPadH * 2.0f * m_scale, kTitleHeight * m_scale); - const float trackLeft = left + (kPadH + kLabelWidth + kLabelGap) * m_scale; + const float contentTop = m_titleRect.Y + m_titleRect.Height + kTitleGap * m_scale; + + // 左侧:色域方块(横轴饱和度、纵轴明度)+ 色相条。 + m_gamutRect = gd::RectF(left + kPadH * m_scale, 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); + + // 右侧:R/G/B 滑块 + 数值,紧随色相条之后。 + const float columnLeft = m_hueRect.X + m_hueRect.Width + kColumnGap * m_scale; + const float columnWidth = + std::max(80.0f * m_scale, m_rect.X + m_rect.Width - kPadH * m_scale - columnLeft); + const float trackLeft = columnLeft + (kLabelWidth + kLabelGap) * m_scale; const float trackWidth = - std::max(40.0f, width - (trackLeft - left) - (kPadH + kValueWidth) * m_scale); - const float firstTop = m_titleRect.Y + m_titleRect.Height + kTitleGap * m_scale; + std::max(24.0f, columnWidth - (kLabelWidth + kLabelGap + kValueWidth) * m_scale); for (int index = 0; index < 3; ++index) { - const float rowTop = firstTop + static_cast(index) * (kRowHeight + kRowGap) * m_scale; + const float rowTop = + contentTop + static_cast(index) * (kRowHeight + kRowGap) * m_scale; m_tracks[static_cast(index)] = gd::RectF(trackLeft, rowTop + (kRowHeight * m_scale - kTrackHeight * m_scale) * 0.5f, trackWidth, kTrackHeight * m_scale); @@ -145,10 +274,11 @@ namespace jdi kValueWidth * m_scale - 6.0f * m_scale, kRowHeight * m_scale); } - const float previewTop = firstTop + 3.0f * (kRowHeight + kRowGap) * m_scale + kPreviewGap * m_scale; + const float previewTop = + contentTop + (3.0f * (kRowHeight + kRowGap) + kPreviewGap) * m_scale; m_previewRect = gd::RectF(trackLeft, previewTop + 2.0f * m_scale, 52.0f * m_scale, 20.0f * m_scale); - m_hexRect = gd::RectF(m_previewRect.X + m_previewRect.Width + 8.0f * m_scale, previewTop + 2.0f * m_scale, - 96.0f * m_scale, 20.0f * m_scale); + m_hexRect = gd::RectF(m_previewRect.X + m_previewRect.Width + 8.0f * m_scale, + previewTop + 2.0f * m_scale, 96.0f * m_scale, 20.0f * m_scale); } void ColorPickerPopup::Open(const gd::RectF& bounds, const gd::RectF& anchor, std::uint32_t color) @@ -161,6 +291,10 @@ namespace jdi m_changed = false; m_draggingSlider = -1; m_hoverSlider = -1; + m_drag = DragTarget::NONE; + // 记住初始色相:之后即使把饱和度拖到 0(灰色)色相也不会丢失。 + m_hue = ToHsv(m_channels).h; + InvalidateGamutCache(); BuildLayout(bounds, anchor); } @@ -175,6 +309,118 @@ namespace jdi 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 + { + const Hsv hsv = ToHsv(m_channels); + // 灰度/黑色时 HSV 的色相没有定义:沿用记忆色相,避免拖动 RGB 滑块时色相跳变、 + // 也保证"饱和度归零后仍能靠色相条换回颜色"。 + return (hsv.s > 1.0e-4) ? hsv.h : m_hue; + } + + void ColorPickerPopup::RefreshHueFromChannels() + { + const Hsv hsv = ToHsv(m_channels); + if (hsv.s > 1.0e-4) + { + m_hue = hsv.h; + } + } + + void ColorPickerPopup::InvalidateGamutCache() + { + m_gamutCacheHue = -1.0; + } + + bool ColorPickerPopup::EnsureGamutCache() + { + const int size = std::max(8, static_cast(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(data.Scan0); + for (int y = 0; y < size; ++y) + { + // 纵轴明度(上 1 → 下 0),横轴饱和度(左 0 → 右 1)——与常见取色器一致。 + const double value = 1.0 - (static_cast(y) + 0.5) / static_cast(size); + unsigned char* row = (data.Stride >= 0) + ? (base + static_cast(y) * data.Stride) + : (base + static_cast(size - 1 - y) * (-data.Stride)); + for (int x = 0; x < size; ++x) + { + const double saturation = + (static_cast(x) + 0.5) / static_cast(size); + const std::array rgb = FromHsv(hue, saturation, value); + row[x * 4 + 0] = static_cast(ToByte(rgb[2])); // B + row[x * 4 + 1] = static_cast(ToByte(rgb[1])); // G + row[x * 4 + 2] = static_cast(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); + const std::array rgb = FromHsv(CurrentHue(), saturation, value); + if (std::fabs(rgb[0] - m_channels[0]) < 1.0e-6 && std::fabs(rgb[1] - m_channels[1]) < 1.0e-6 && + std::fabs(rgb[2] - m_channels[2]) < 1.0e-6) + { + return; + } + m_channels = rgb; + // 色相不变(明度归零时 FromHsv 会给出黑色,但记忆色相仍要保留)。 + m_changed = true; + } + + void ColorPickerPopup::ApplyHue(float y) + { + if (m_hueRect.Height <= 0.0f) + { + return; + } + const double hue = Clamp01((y - m_hueRect.Y) / m_hueRect.Height); + const Hsv hsv = ToHsv(m_channels); + m_hue = hue; + const std::array rgb = FromHsv(m_hue, hsv.s, hsv.v); + if (std::fabs(rgb[0] - m_channels[0]) < 1.0e-6 && std::fabs(rgb[1] - m_channels[1]) < 1.0e-6 && + std::fabs(rgb[2] - m_channels[2]) < 1.0e-6) + { + return; + } + m_channels = rgb; + m_changed = true; } gd::RectF ColorPickerPopup::EditFieldRect() const @@ -292,6 +538,7 @@ namespace jdi m_channels[0] = static_cast((value >> 16) & 0xFFu) / 255.0; m_channels[1] = static_cast((value >> 8) & 0xFFu) / 255.0; m_channels[2] = static_cast(value & 0xFFu) / 255.0; + RefreshHueFromChannels(); m_changed = true; } } @@ -323,6 +570,7 @@ namespace jdi if (index >= 0 && index < 3) { m_channels[static_cast(index)] = value / 255.0; + RefreshHueFromChannels(); m_changed = true; } return true; @@ -417,8 +665,18 @@ namespace jdi return nullptr; } - // 拖拽过程中始终保持横向调整光标,避免指针移出轨道后光标跳变。 - if (m_draggingSlider >= 0 || SliderAtPoint(x, y) >= 0) + // 拖拽过程中始终保持对应光标,避免指针移出控件后光标跳变。 + if (m_drag == DragTarget::GAMUT || UiPointInRect(m_gamutRect, static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_CROSS); + } + if (m_drag == DragTarget::HUE || + UiPointInRect(m_hueRect, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZENS); + } + if (m_drag == DragTarget::SLIDER || m_draggingSlider >= 0 || SliderAtPoint(x, y) >= 0) { return LoadCursorW(nullptr, IDC_SIZEWE); } @@ -461,6 +719,7 @@ namespace jdi return; } m_channels[slot] = ratio; + RefreshHueFromChannels(); m_changed = true; } @@ -471,10 +730,20 @@ namespace jdi return false; } - if (m_draggingSlider >= 0) + switch (m_drag) { + case DragTarget::GAMUT: + ApplyGamut(static_cast(x), static_cast(y)); + return true; + case DragTarget::HUE: + ApplyHue(static_cast(y)); + return true; + case DragTarget::SLIDER: ApplySlider(m_draggingSlider, static_cast(x)); return true; + case DragTarget::NONE: + default: + break; } const int hover = SliderAtPoint(x, y); @@ -523,9 +792,24 @@ namespace jdi return true; } + // 色域方块与色相条:按下即取值,之后拖动持续更新。 + if (UiPointInRect(m_gamutRect, static_cast(x), static_cast(y))) + { + m_drag = DragTarget::GAMUT; + ApplyGamut(static_cast(x), static_cast(y)); + return true; + } + if (UiPointInRect(m_hueRect, static_cast(x), static_cast(y))) + { + m_drag = DragTarget::HUE; + ApplyHue(static_cast(y)); + return true; + } + m_draggingSlider = SliderAtPoint(x, y); if (m_draggingSlider >= 0) { + m_drag = DragTarget::SLIDER; ApplySlider(m_draggingSlider, static_cast(x)); return true; } @@ -534,11 +818,12 @@ namespace jdi bool ColorPickerPopup::OnLButtonUp() { - if (!m_open || m_draggingSlider < 0) + if (!m_open || (m_draggingSlider < 0 && m_drag == DragTarget::NONE)) { return false; } m_draggingSlider = -1; + m_drag = DragTarget::NONE; return true; } @@ -617,6 +902,54 @@ namespace jdi UiText(g, L"自定义颜色", m_fonts->uiSmallBold, m_titleRect, m_theme->textMuted, TextAlign::LEFT); + // ---- 色域方块(横轴饱和度、纵轴明度)---- + // 方块按当前色相预渲染到缓存位图里,避免每帧重画上百条渐变; + // 色相变化时才重建一次。 + if (EnsureGamutCache()) + { + g.DrawImage(m_gamutCache.get(), m_gamutRect); + } + UiDrawRoundRect(g, m_gamutRect, 2.0f * m_scale, m_theme->borderStrong, kGamutBorder); + + // ---- 色相条(纵向 0-1,用 6 段线性渐变近似色相环)---- + { + const Hsv hsv = ToHsv(m_channels); + const double saturation = (hsv.s > 1.0e-4) ? hsv.s : 1.0; + const double value = (hsv.v > 1.0e-4) ? hsv.v : 1.0; + const int bands = std::max(12, static_cast(std::lround(m_hueRect.Height))); + for (int index = 0; index < bands; ++index) + { + const double hue = (static_cast(index) + 0.5) / static_cast(bands); + const std::array rgb = FromHsv(hue, 1.0, 1.0); + const std::uint32_t band = 0xFF000000u | (ToByte(rgb[0]) << 16) | (ToByte(rgb[1]) << 8) | + ToByte(rgb[2]); + const float top = m_hueRect.Y + m_hueRect.Height * index / static_cast(bands); + const float bottom = + m_hueRect.Y + m_hueRect.Height * (index + 1) / static_cast(bands); + UiFillRect(g, gd::RectF(m_hueRect.X, top, m_hueRect.Width, bottom - top), band); + } + UiDrawRoundRect(g, m_hueRect, 2.0f * m_scale, m_theme->borderStrong, kGamutBorder); + + // 控件指示器:色域方块上标出 (S, V),色相条上标出当前色相。 + // 两者都由 m_channels 反推,因此与 R/G/B 滑块、十六进制输入天然联动。 + const float markerX = m_gamutRect.X + static_cast(saturation) * m_gamutRect.Width; + const float markerY = m_gamutRect.Y + static_cast(1.0 - value) * m_gamutRect.Height; + gd::Pen outerPen(ToGdiColor(0xFF1B1B1Bu), 2.0f * m_scale); + gd::Pen innerPen(ToGdiColor(0xFFFFFFFFu), 1.4f * m_scale); + const float outerRadius = 5.0f * m_scale; + g.DrawEllipse(&outerPen, markerX - outerRadius, markerY - outerRadius, outerRadius * 2.0f, + outerRadius * 2.0f); + const float innerRadius = 3.4f * m_scale; + g.DrawEllipse(&innerPen, markerX - innerRadius, markerY - innerRadius, innerRadius * 2.0f, + innerRadius * 2.0f); + + const float hueMarkerY = m_hueRect.Y + static_cast(CurrentHue()) * m_hueRect.Height; + UiFillRect(g, gd::RectF(m_hueRect.X - 2.0f * m_scale, hueMarkerY - 1.5f * m_scale, + m_hueRect.Width + 4.0f * m_scale, 3.0f * m_scale), + 0xFF1B1B1Bu); + UiDrawRoundRect(g, m_hueRect, 2.0f * m_scale, m_theme->borderStrong, kGamutBorder); + } + for (int index = 0; index < 3; ++index) { const std::size_t slot = static_cast(index); diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index 749175e..d2ab49b 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -372,7 +372,38 @@ namespace jdi } return ratio; } + double UiColorLuminance(std::uint32_t color) + { + const double r = static_cast((color >> 16) & 0xFFu); + const double g = static_cast((color >> 8) & 0xFFu); + const double b = static_cast(color & 0xFFu); + return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0; + } + UiInk UiContrastInk(std::uint32_t background) + { + // 极值文字/描边色(Solarized base3 / base02 / base03),亮度差很大, + // 因此任意底色下文字与底色之间都有充足对比。 + const std::uint32_t kInkLight = 0xFFFDF6E3u; + const std::uint32_t kInkDark = 0xFF073642u; + const std::uint32_t kOutlineLight = 0xFFFDF6E3u; + const std::uint32_t kOutlineDark = 0xFF002B36u; + /** 向底色互补色混入的比例:线性响应信号配色,同时把对比度损失限制在 15% 以内。 */ + const double kTint = 0.15; + + const double luminance = UiColorLuminance(background); + // 底色越亮 → 文字越暗(取最大对比的一侧)。 + const bool darkText = (luminance >= 0.5); + // 底色的互补色:相对亮度恰为 1 - 底色亮度。 + const std::uint32_t complement = 0xFF000000u | ((~background) & 0x00FFFFFFu); + + UiInk ink; + ink.text = MixColor(darkText ? kInkDark : kInkLight, complement, kTint); + ink.text = WithAlpha(ink.text, 0xFFu); + ink.outline = MixColor(darkText ? kOutlineLight : kOutlineDark, complement, kTint); + ink.outline = WithAlpha(ink.outline, 0x99u); + return ink; + } void UiDrawCheckBox(gd::Graphics& g, const gd::RectF& rect, int state, float radius) { const Theme& theme = SolarizedLight();