Files
Just-DataInspector/Utilities/Src/PaintUtil.cpp
T
DeepSeek V4.1 Flash a2a38236fe 边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开
一、两个「边沿计数」按「画出来的曲线」判定(原先按原始采样值 → 应用过「调整」的信号恒为 0)
- 用户复述的权威定义:设定一个横向(Y 轴)光标,信号绘制出来的曲线从下往上穿过该幅值 = 上升沿,
  从上往下穿过 = 下降沿。
- 症状:VERY_BIG_DATA 的「编码器B相」设系数 50 + 时间轴偏移 -20s,把光标拖到画面中间,
  很规整的方波却统计出 0/0。
- 根因:EdgeCountsFor / EdgeCenterCountsFor 只比较 signal.values 的原始采样值,而横向光标读的是
  屏幕上的值(已乘系数、已加偏移)→ 原始值域与阈值完全不相交。
- 修法:判定值一律取 ApplyDrawTransform(style, values[SourceIndexFor(style, count, index)]);
  * 中心轴两侧必须套用同一套变换(drawn - drawnCenter);
  * 水平镜像时曲线数组按源下标存储、而统计区间是显示下标 → 请求曲线窗口前必须把
    [needFirst, needEnd) 映射为 [count-needEnd, count-needFirst);
  * 缓存必须带样式指纹:EdgeCountEntry / EdgeCenterCountEntry 新增 styleKey
    (EdgeStyleKey():gain/offsetY 按 1e6 定点 + absolute/mirrorVertical/mirrorHorizontal 位),
    否则改完「调整」仍会命中变换前的旧计数。

二、时间区间之外的采样不得计入(区间边界点)
- 症状:A 在阈值以下、B 超过阈值;双光标下一个光标正好在 B 点,旧实现把 B 算成一个上升沿。
- 根因:EdgeIndexRange 在左端多取了一个采样点(if (begin > 0) --begin;)。
- 修法:删掉该外扩,收紧为「只有两个端点都落在 [begin, end) 内的采样对才计入」
  (begin = lower_bound(rangeMin)、end = upper_bound(rangeMax),调用方从 begin+1 枚举到 end-1)。
- 结果:把区间起点从 times[crossing-1] 移到 times[crossing] 恰好少一个上升沿。

三、派生信号把 offsetTime 也烘进采样值(推翻 §9.19 的「唯一例外」)
- 用户要求:新信号默认不启用任何「调整」时就必须与网格里的预览逐点重合。
- 修法(Application::BuildDerivedSignal,在「值变换 + 水平镜像反序」之后追加一步):
  新信号第 i 个采样 = 已变换的曲线在 time[i] - offsetTime 处的取值(取最近的采样点);
  单调时间轴用只前进的游标(整段 O(n));平移后越界的采样置 NaN(与预览一致);
  新信号样式全部保持默认(gain=1 / absolute=false / 两个镜像=false / offsetY=0 / offsetTime=0)。
- 退化路径:时间轴非单调或时间列缺失时无法按下标推进取样,退回把 offsetTime 带在样式上
  (两条路径的显示效果相同)。

四、图例托底统一为 GX 编号徽标同款 + 新增更小字体 uiTiny
- 托底高度 = kLaneBadgeHeight、上下边界与 LaneBadgeRect() 完全一致、圆角 = 高度的一半(胶囊形)、
  配色与描边与徽标相同(选中态 accentSoft/accent)、水平内边距 = 胶囊半径。
- 文本改用新增字体 UiFonts::uiTiny(10 逻辑 px):原 uiSmall(12px) 几乎顶满 17px 高的胶囊。
- LegendItem 拆出 plateWidth(托底宽 = 内容宽 + 2×内边距),条目步进 = plateWidth + kLegendItemGap;
  kLegendPlateRadius 已删除(半径一律由徽标高度推出)。

五、「系数/偏移」「镜像」二级面板改为向上展开
- PopupMenu::Open(..., bool expandUp = false):true 时 (x, y) 是菜单的左下角、菜单向上展开;
  BuildLayout 增加同参数并记录 m_buildExpandUp(RebuildLayout 必须复用,否则数值回写触发的重排
  会让面板跳回向下展开)。
- SignalPanel::OpenGainMenu / OpenMirrorMenu:锚点 = (button.X, button.Y - kAdjustMenuGap),
  即「面板左下角对准按钮左上角、再略微上去一点」(kAdjustMenuGap = 4 逻辑像素)。

六、验证(全部离屏,不占用用户的鼠标 / 键盘 / 屏幕)
- .preflight/uitest 新增 [AR]:
  * 边沿计数 plain / gain=50 / offsetY / mirrorVertical / mirrorHorizontal 逐个与「独立参考实现」
    逐位一致;offsetY 用例给出 A/B:按原始采样值算会得到 7/6 个边沿(正是用户报告的缺陷);
  * 区间边界:起点从 crossing-1 移到 crossing 恰好少一个上升沿;
  * 中心轴:absolute + offsetY 的样式下与参考实现逐位一致(32234 个边沿量级);
  * 派生信号:无偏移时全部 128 万采样逐点等于 transform(滤波);带 +10 个平均采样周期的偏移时,
    抽样点等于 transform(滤波) 在 time[i] - offsetTime(最近采样)处的取值;样式全默认;
  * 图例托底:height == 徽标高度、Y == 徽标 Y、radius == height/2、色块与文本都在托底之内;
  * 调整面板:菜单左边界 == 按钮左边界、菜单底 == 按钮顶 - 4*scale、且不覆盖按钮(两个按钮各测一次);
  * GX 拖拽让位:±0.6H 各让位 1 格(目标 0 / 3),±0.4H 与恰好 ±0.5H 都不让位(严格「超过」)。
- 同步修正 harness 里两处「旧规则」断言(否则必然 FAIL 并误导后来者):
  [AP] 「派生信号继承调整变换」→ 「样式默认 + 采样值按 time[i]-offsetTime 烘焙」;
  [AN] 「图例带无托底(网格线可透出)」→ 「装饰矩形逐块、无一块跨越整条带、未被托底覆盖的列
  仍能看到底层内容」。
- 合计 722/722 通过;make release 零警告(本机 GCC 15.2.0 UCRT;Target.exe 被占用时链接会报
  Permission denied,已用临时 -o 名字验证链接正确性)。

规则同步:Documents/agent_mem.md 新增 §9.21,§9.19 的「offsetTime 仍需继承」标注为已作废,
新增 §11.2 第 21–25 条硬约束,并补变更日志一行。
2026-10-14 11:40:49 +08:00

902 lines
28 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 "PaintUtil.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <functional>
#include <mutex>
#include <unordered_map>
#include <utility>
#include <string>
namespace jdi
{
namespace
{
/**
* @brief 颜色样块补充描边的不透明度阈值。
*
* 不透明度低于该值时,色块自身与浅色背景的对比度已不足,需要额外描边才能辨识;
* 高于该值时按需求不绘制描边,保持与预设色块一致的纯净外观。
*/
const float kSwatchOutlineOpacity = 0.45f;
/**
* @brief 矩形面积超过该阈值(逻辑像素²)且为不透明色时,改用 GDI 填充。
*
* GDI+ 的 FillRectangle 约 2.2ns/像素;面积过小时 GDI 的 GetHDC/ReleaseHDC
* 固定开销反而不划算,因此在阈值以下仍走 GDI+。
*/
const float kGdiFillAreaThreshold = 20000.0f;
}
// 圆角矩形路径是公开的几何构建器:`UiFillRoundRect` / `UiDrawRoundRect` / 需要把位图或渐变
// **裁剪进同一个圆角形状**的调用方共用它,保证"填充的形状"与"描边的形状"逐点一致。
void UiBuildRoundRectPath(gd::GraphicsPath& path, const gd::RectF& rect, float radius)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
{
return;
}
float diameter = radius * 2.0f;
if (diameter > rect.Width)
{
diameter = rect.Width;
}
if (diameter > rect.Height)
{
diameter = rect.Height;
}
if (diameter < 1.0f)
{
path.AddRectangle(rect);
return;
}
const float right = rect.X + rect.Width;
const float bottom = rect.Y + rect.Height;
path.AddArc(rect.X, rect.Y, diameter, diameter, 180.0f, 90.0f);
path.AddArc(right - diameter, rect.Y, diameter, diameter, 270.0f, 90.0f);
path.AddArc(right - diameter, bottom - diameter, diameter, diameter, 0.0f, 90.0f);
path.AddArc(rect.X, bottom - diameter, diameter, diameter, 90.0f, 90.0f);
path.CloseFigure();
}
namespace
{
/**
* @brief 创建指定字号的界面字体。
* @param [IN] face 字体名称。
* @param [IN] pixelHeight 字符高度(像素)。
* @param [IN] bold 是否加粗。
* @return 字体句柄,失败返回 nullptr。
*/
HFONT CreateUiFont(const wchar_t* face, int pixelHeight, bool bold)
{
LOGFONTW logFont = {};
logFont.lfHeight = -pixelHeight;
logFont.lfWeight = bold ? FW_SEMIBOLD : FW_NORMAL;
logFont.lfCharSet = DEFAULT_CHARSET;
logFont.lfOutPrecision = OUT_TT_PRECIS;
logFont.lfClipPrecision = CLIP_DEFAULT_PRECIS;
logFont.lfQuality = CLEARTYPE_QUALITY;
logFont.lfPitchAndFamily = DEFAULT_PITCH | FF_DONTCARE;
wcsncpy(logFont.lfFaceName, face, LF_FACESIZE - 1);
logFont.lfFaceName[LF_FACESIZE - 1] = L'\0';
HFONT font = CreateFontIndirectW(&logFont);
if (font == nullptr)
{
font = static_cast<HFONT>(GetStockObject(DEFAULT_GUI_FONT));
}
return font;
}
} // namespace
namespace
{
/**
* @brief 文本宽度测量的缓存键(字体句柄 + 文本)。
*/
struct MeasureKey
{
HFONT font = nullptr;
std::wstring text;
bool operator==(const MeasureKey& other) const
{
return font == other.font && text == other.text;
}
};
struct MeasureKeyHash
{
std::size_t operator()(const MeasureKey& key) const
{
std::size_t hash = std::hash<const void*>()(key.font);
for (const wchar_t character : key.text)
{
hash = hash * 131u + static_cast<std::size_t>(character);
}
return hash;
}
};
/** 上限保护:超过时整体清空(缓存只是加速手段,清空后重新填充即可)。 */
constexpr std::size_t kMeasureCacheLimit = 4096;
std::unordered_map<MeasureKey, float, MeasureKeyHash> g_measureCache;
std::mutex g_measureMutex;
} // namespace
void UiClearMeasureCache()
{
std::lock_guard<std::mutex> lock(g_measureMutex);
g_measureCache.clear();
}
bool UiFonts::Create(UINT targetDpi)
{
Destroy();
// 字体句柄会被重新创建,旧句柄可能被系统复用:必须作废按句柄缓存的文本宽度,
// 否则复用同一句柄值的新字体会命中旧宽度(布局错位)。
UiClearMeasureCache();
dpi = (targetDpi == 0) ? 96u : targetDpi;
const double scale = static_cast<double>(dpi) / 96.0;
const int uiHeight = static_cast<int>(std::lround(14.0 * scale));
const int smallHeight = static_cast<int>(std::lround(12.0 * scale));
const int tinyHeight = static_cast<int>(std::lround(10.0 * scale));
const int titleHeight = static_cast<int>(std::lround(15.0 * scale));
const int monoHeight = static_cast<int>(std::lround(13.0 * scale));
ui = CreateUiFont(L"Segoe UI", uiHeight, false);
uiBold = CreateUiFont(L"Segoe UI Semibold", uiHeight, true);
uiSmall = CreateUiFont(L"Segoe UI", smallHeight, false);
uiSmallBold = CreateUiFont(L"Segoe UI Semibold", smallHeight, true);
uiTiny = CreateUiFont(L"Segoe UI", tinyHeight, false);
title = CreateUiFont(L"Segoe UI Semibold", titleHeight, true);
mono = CreateUiFont(L"Consolas", monoHeight, false);
monoSmall = CreateUiFont(L"Consolas", smallHeight, false);
return ui != nullptr && uiBold != nullptr && uiSmall != nullptr && uiTiny != nullptr &&
title != nullptr && mono != nullptr;
}
void UiFonts::Destroy()
{
const HFONT resources[] = {ui, uiBold, uiSmall, uiSmallBold, uiTiny, title, mono, monoSmall};
for (const HFONT font : resources)
{
if (font != nullptr)
{
DeleteObject(font);
}
}
ui = nullptr;
uiBold = nullptr;
uiSmall = nullptr;
uiSmallBold = nullptr;
uiTiny = nullptr;
title = nullptr;
mono = nullptr;
monoSmall = nullptr;
}
void UiFillRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
{
return;
}
// 大面积且**不透明**的填充走 GDI 路线:GDI+ 的 FillRectangle 实测约 2.2ns/像素
// (整个窗口背景 1.7M 像素约 0.9ms,信号面板背景约 1.9ms),
// 而 GDI 的 FillRect 快一个数量级。半透明填充必须留在 GDI+(GDI 不做 alpha 混色)。
// 顺序安全:GetHDC() 会先把已排队的 GDI+ 内容刷入 DC,本次填充之后再发生的 GDI+ 绘制
// 会在 ReleaseHDC() 之后刷出、正确地覆盖在上面。
const float area = rect.Width * rect.Height;
if ((argb & 0xFF000000u) == 0xFF000000u && area >= kGdiFillAreaThreshold)
{
HDC hdc = g.GetHDC();
if (hdc != nullptr)
{
RECT rc;
rc.left = static_cast<LONG>(std::floor(rect.X));
rc.top = static_cast<LONG>(std::floor(rect.Y));
rc.right = static_cast<LONG>(std::ceil(rect.X + rect.Width));
rc.bottom = static_cast<LONG>(std::ceil(rect.Y + rect.Height));
HBRUSH brush = CreateSolidBrush(ToColorRef(argb));
if (brush != nullptr)
{
FillRect(hdc, &rc, brush);
DeleteObject(brush);
}
g.ReleaseHDC(hdc);
return;
}
}
gd::SolidBrush brush(ToGdiColor(argb));
g.FillRectangle(&brush, rect);
}
void UiFillRoundRect(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t argb)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
{
return;
}
gd::GraphicsPath path;
UiBuildRoundRectPath(path, rect, radius);
gd::SolidBrush brush(ToGdiColor(argb));
g.FillPath(&brush, &path);
}
void UiDrawRoundRect(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t argb, float width)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
{
return;
}
gd::GraphicsPath path;
UiBuildRoundRectPath(path, rect, radius);
gd::Pen pen(ToGdiColor(argb), width);
g.DrawPath(&pen, &path);
}
void UiFillRoundRects(gd::Graphics& g, const std::vector<gd::RectF>& rects, float radius,
std::uint32_t argb)
{
if (rects.empty())
{
return;
}
gd::GraphicsPath path;
for (const gd::RectF& rect : rects)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
{
continue;
}
UiBuildRoundRectPath(path, rect, radius);
}
if (path.GetPointCount() > 0)
{
gd::SolidBrush brush(ToGdiColor(argb));
g.FillPath(&brush, &path);
}
}
std::uint32_t UiBlendOver(std::uint32_t background, std::uint32_t argb)
{
const unsigned int alpha = (argb >> 24) & 0xFFu;
if (alpha >= 0xFFu)
{
return argb | 0xFF000000u;
}
if (alpha == 0u)
{
return background | 0xFF000000u;
}
const double ratio = static_cast<double>(alpha) / 255.0;
std::uint32_t result = 0xFF000000u;
for (int shift = 0; shift <= 16; shift += 8)
{
const double top = static_cast<double>((argb >> shift) & 0xFFu);
const double bottom = static_cast<double>((background >> shift) & 0xFFu);
const double mixed = bottom + (top - bottom) * ratio;
result |= (static_cast<std::uint32_t>(mixed + 0.5) & 0xFFu) << shift;
}
return result;
}
void UiDrawSolidLines(gd::Graphics& g, const gd::RectF& clip, const float* segments,
std::size_t segmentCount, std::uint32_t argb, int width)
{
if (segments == nullptr || segmentCount == 0 || (argb & 0xFF000000u) != 0xFF000000u)
{
return;
}
if (width < 1)
{
width = 1;
}
const int clipLeft = static_cast<int>(std::floor(clip.X));
const int clipTop = static_cast<int>(std::floor(clip.Y));
const int clipRight = static_cast<int>(std::ceil(clip.X + clip.Width));
const int clipBottom = static_cast<int>(std::ceil(clip.Y + clip.Height));
if (clipRight <= clipLeft || clipBottom <= clipTop)
{
return;
}
HDC hdc = g.GetHDC();
if (hdc == nullptr)
{
return;
}
// GDI 绘制不受 GDI+ 裁剪约束,这里显式设置并随后完整还原 DC 状态。
const int saved = SaveDC(hdc);
if (saved != 0)
{
IntersectClipRect(hdc, clipLeft, clipTop, clipRight, clipBottom);
}
HPEN pen = CreatePen(PS_SOLID, width, ToColorRef(argb));
if (pen != nullptr)
{
HGDIOBJ oldPen = SelectObject(hdc, pen);
for (std::size_t index = 0; index < segmentCount; ++index)
{
const float* segment = segments + index * 4;
MoveToEx(hdc, static_cast<int>(std::lround(segment[0])),
static_cast<int>(std::lround(segment[1])), nullptr);
LineTo(hdc, static_cast<int>(std::lround(segment[2])),
static_cast<int>(std::lround(segment[3])));
}
SelectObject(hdc, oldPen);
DeleteObject(pen);
}
if (saved != 0)
{
RestoreDC(hdc, saved);
}
g.ReleaseHDC(hdc);
}
void UiDrawLine(gd::Graphics& g, float x1, float y1, float x2, float y2, std::uint32_t argb, float width)
{
gd::Pen pen(ToGdiColor(argb), width);
pen.SetStartCap(gd::LineCapFlat);
pen.SetEndCap(gd::LineCapFlat);
g.DrawLine(&pen, x1, y1, x2, y2);
}
void UiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb,
TextAlign align)
{
if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f)
{
return;
}
HDC hdc = g.GetHDC();
if (hdc == nullptr)
{
return;
}
HGDIOBJ oldFont = SelectObject(hdc, font);
const int oldMode = SetBkMode(hdc, TRANSPARENT);
const COLORREF oldColor = SetTextColor(hdc, ToColorRef(argb));
RECT rc;
rc.left = static_cast<LONG>(std::floor(rect.X));
rc.top = static_cast<LONG>(std::floor(rect.Y));
rc.right = static_cast<LONG>(std::ceil(rect.X + rect.Width));
rc.bottom = static_cast<LONG>(std::ceil(rect.Y + rect.Height));
UINT flags = DT_SINGLELINE | DT_VCENTER | DT_NOPREFIX | DT_END_ELLIPSIS;
switch (align)
{
case TextAlign::CENTER:
flags |= DT_CENTER;
break;
case TextAlign::RIGHT:
flags |= DT_RIGHT;
break;
case TextAlign::LEFT:
default:
flags |= DT_LEFT;
break;
}
DrawTextW(hdc, text.c_str(), static_cast<int>(text.size()), &rc, flags);
SetTextColor(hdc, oldColor);
SetBkMode(hdc, oldMode);
SelectObject(hdc, oldFont);
g.ReleaseHDC(hdc);
}
float UiMeasureTextWidth(HFONT font, const std::wstring& text)
{
if (text.empty() || font == nullptr)
{
return 0.0f;
}
// 记忆化:文本宽度只能走 GDI(GetDC + GetTextExtentPoint32W + ReleaseDC),
// 单次约 10µs;而布局代码每帧会把同一批字符串**重复测量成百上千次**——
// 例如浮动面板的几何是按「网格数 × 面板种类」反复重算的(5 网格 × 4 类 = 20 次,
// 每次十来个字符串),实测光这一项就占 3ms 以上。字体与文本都相同的测量结果
// 必然一致,因此按 (字体, 文本) 做一层缓存;字体被重建时由 `UiClearMeasureCache` 作废。
const MeasureKey key{font, text};
{
std::lock_guard<std::mutex> lock(g_measureMutex);
const auto found = g_measureCache.find(key);
if (found != g_measureCache.end())
{
return found->second;
}
}
HDC hdc = GetDC(nullptr);
if (hdc == nullptr)
{
return 0.0f;
}
HGDIOBJ oldFont = SelectObject(hdc, font);
SIZE size = {};
GetTextExtentPoint32W(hdc, text.c_str(), static_cast<int>(text.size()), &size);
SelectObject(hdc, oldFont);
ReleaseDC(nullptr, hdc);
const float width = static_cast<float>(size.cx);
{
std::lock_guard<std::mutex> lock(g_measureMutex);
if (g_measureCache.size() >= kMeasureCacheLimit)
{
g_measureCache.clear();
}
g_measureCache.emplace(key, width);
}
return width;
}
std::wstring UiEllipsize(const std::wstring& text, HFONT font, float maxWidth)
{
if (text.empty() || font == nullptr || maxWidth <= 0.0f)
{
return std::wstring();
}
if (UiMeasureTextWidth(font, text) <= maxWidth)
{
return text;
}
const std::wstring ellipsis = L"…";
const float ellipsisWidth = UiMeasureTextWidth(font, ellipsis);
if (ellipsisWidth > maxWidth)
{
return std::wstring();
}
std::wstring result = text;
while (!result.empty())
{
result.pop_back();
if (UiMeasureTextWidth(font, result) + ellipsisWidth <= maxWidth)
{
return result + ellipsis;
}
}
return ellipsis;
}
void UiDrawPlusIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth)
{
const float cx = rect.X + rect.Width * 0.5f;
const float cy = rect.Y + rect.Height * 0.5f;
const float arm = std::min(rect.Width, rect.Height) * 0.3f;
UiDrawLine(g, cx - arm, cy, cx + arm, cy, argb, strokeWidth);
UiDrawLine(g, cx, cy - arm, cx, cy + arm, argb, strokeWidth);
}
void UiDrawCrossIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth)
{
const float cx = rect.X + rect.Width * 0.5f;
const float cy = rect.Y + rect.Height * 0.5f;
const float arm = std::min(rect.Width, rect.Height) * 0.3f;
UiDrawLine(g, cx - arm, cy - arm, cx + arm, cy + arm, argb, strokeWidth);
UiDrawLine(g, cx + arm, cy - arm, cx - arm, cy + arm, argb, strokeWidth);
}
void UiDrawTriangleIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, IconDirection direction)
{
const float cx = rect.X + rect.Width * 0.5f;
const float cy = rect.Y + rect.Height * 0.5f;
const float arm = std::min(rect.Width, rect.Height) * 0.28f;
gd::PointF points[3];
switch (direction)
{
case IconDirection::LEFT:
points[0] = gd::PointF(cx + arm * 0.7f, cy - arm);
points[1] = gd::PointF(cx - arm * 0.7f, cy);
points[2] = gd::PointF(cx + arm * 0.7f, cy + arm);
break;
case IconDirection::RIGHT:
points[0] = gd::PointF(cx - arm * 0.7f, cy - arm);
points[1] = gd::PointF(cx + arm * 0.7f, cy);
points[2] = gd::PointF(cx - arm * 0.7f, cy + arm);
break;
case IconDirection::UP:
points[0] = gd::PointF(cx - arm, cy + arm * 0.7f);
points[1] = gd::PointF(cx, cy - arm * 0.7f);
points[2] = gd::PointF(cx + arm, cy + arm * 0.7f);
break;
case IconDirection::DOWN:
default:
points[0] = gd::PointF(cx - arm, cy - arm * 0.7f);
points[1] = gd::PointF(cx, cy + arm * 0.7f);
points[2] = gd::PointF(cx + arm, cy - arm * 0.7f);
break;
}
gd::SolidBrush brush(ToGdiColor(argb));
g.FillPolygon(&brush, points, 3);
}
double UiSliderRatioFromX(const gd::RectF& trackRect, float x)
{
if (trackRect.Width <= 0.0f)
{
return 0.0;
}
const double ratio = static_cast<double>(x - trackRect.X) / static_cast<double>(trackRect.Width);
if (ratio < 0.0)
{
return 0.0;
}
if (ratio > 1.0)
{
return 1.0;
}
return ratio;
}
double UiColorLuminance(std::uint32_t color)
{
const double r = static_cast<double>((color >> 16) & 0xFFu);
const double g = static_cast<double>((color >> 8) & 0xFFu);
const double b = static_cast<double>(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();
const float centerY = rect.Y + rect.Height * 0.5f;
if (state == 1)
{
UiFillRoundRect(g, rect, radius, theme.accent);
gd::Pen pen(ToGdiColor(theme.textOnAccent), std::max(1.4f, rect.Height * 0.11f));
pen.SetStartCap(gd::LineCapRound);
pen.SetEndCap(gd::LineCapRound);
g.DrawLine(&pen, rect.X + rect.Width * 0.22f, centerY, rect.X + rect.Width * 0.42f,
rect.Y + rect.Height * 0.72f);
g.DrawLine(&pen, rect.X + rect.Width * 0.42f, rect.Y + rect.Height * 0.72f,
rect.X + rect.Width * 0.78f, rect.Y + rect.Height * 0.28f);
}
else if (state == 2)
{
UiFillRoundRect(g, rect, radius, theme.accentSoft);
UiDrawRoundRect(g, rect, radius, theme.accent, 1.0f);
UiFillRect(g, gd::RectF(rect.X + rect.Width * 0.22f, centerY - rect.Height * 0.08f,
rect.Width * 0.56f, rect.Height * 0.16f),
theme.accent);
}
else
{
UiFillRoundRect(g, rect, radius, theme.cardBg);
UiDrawRoundRect(g, rect, radius, theme.borderStrong, 1.0f);
}
}
void UiDrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor,
bool active, float scale)
{
if (track.Width <= 0.0f || track.Height <= 0.0f)
{
return;
}
if (ratio < 0.0)
{
ratio = 0.0;
}
if (ratio > 1.0)
{
ratio = 1.0;
}
const float radius = track.Height * 0.5f;
UiFillRoundRect(g, track, radius, 0x2A657B83u);
// 已填充部分严格止于圆钮中心,最小值时不会留下超出圆钮的填充残留。
const float fillWidth = static_cast<float>(ratio) * track.Width;
if (fillWidth > 0.5f)
{
UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), radius, fillColor);
}
const float knobRadius = radius + (active ? 2.5f * scale : 0.8f * scale);
const float knobX = track.X + static_cast<float>(ratio) * track.Width;
gd::SolidBrush knob(ToGdiColor(0xFFFFFFFFu));
gd::Pen knobPen(ToGdiColor(0xFF586E75u), 1.4f * scale);
g.FillEllipse(&knob, knobX - knobRadius, track.Y + radius - knobRadius, knobRadius * 2.0f,
knobRadius * 2.0f);
g.DrawEllipse(&knobPen, knobX - knobRadius, track.Y + radius - knobRadius, knobRadius * 2.0f,
knobRadius * 2.0f);
}
float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale,
float& innerWidth)
{
innerWidth = std::max(4.0f, rect.Width - 12.0f * scale);
const float textLeft = rect.X + 6.0f * scale;
const std::wstring& text = edit.Text();
if (text.empty())
{
return textLeft;
}
const float textWidth = UiMeasureTextWidth(font, text);
if (textWidth <= innerWidth)
{
return textLeft;
}
// 保证插入符可见:插入符超出右边界时向左滚动,并夹到总可滚动范围内。
const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret()));
float scroll = 0.0f;
if (caretX > innerWidth)
{
scroll = caretX - innerWidth;
}
const float maximum = textWidth - innerWidth;
scroll = std::max(0.0f, std::min(scroll, maximum));
return textLeft - scroll;
}
std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX)
{
if (text.empty() || font == nullptr)
{
return 0;
}
if (localX <= 0.0f)
{
return 0;
}
// 逐字符累加宽度,找到距离最近的字符边界(文本通常很短,线性扫描足够)。
float previous = 0.0f;
for (std::size_t index = 0; index < text.size(); ++index)
{
const float current = UiMeasureTextWidth(font, text.substr(0, index + 1));
if (localX < current)
{
// 落在本字符的左半 → 取左边界,右半 → 取右边界。
return (localX - previous <= current - localX) ? index : (index + 1);
}
previous = current;
}
return text.size();
}
void UiTextAtClipped(gd::Graphics& g, const std::wstring& text, HFONT font, float x, const gd::RectF& rect,
std::uint32_t argb, const gd::RectF& clipRect)
{
if (text.empty() || font == nullptr || clipRect.Width <= 0.0f || clipRect.Height <= 0.0f)
{
return;
}
HDC hdc = g.GetHDC();
if (hdc == nullptr)
{
return;
}
HGDIOBJ oldFont = SelectObject(hdc, font);
const int oldMode = SetBkMode(hdc, TRANSPARENT);
const COLORREF oldColor = SetTextColor(hdc, ToColorRef(argb));
TEXTMETRICW metrics = {};
GetTextMetricsW(hdc, &metrics);
const int baselineY = static_cast<int>(
std::floor(rect.Y + (rect.Height - static_cast<float>(metrics.tmHeight)) * 0.5f));
RECT clip = {};
clip.left = static_cast<LONG>(std::floor(clipRect.X));
clip.top = static_cast<LONG>(std::floor(clipRect.Y));
clip.right = static_cast<LONG>(std::ceil(clipRect.X + clipRect.Width));
clip.bottom = static_cast<LONG>(std::ceil(clipRect.Y + clipRect.Height));
// ETO_CLIPPED 让 GDI 在指定矩形内裁剪,长文本不会再画到输入框之外。
ExtTextOutW(hdc, static_cast<int>(std::floor(x)), baselineY, ETO_CLIPPED, &clip, text.c_str(),
static_cast<UINT>(text.size()), nullptr);
SetTextColor(hdc, oldColor);
SetBkMode(hdc, oldMode);
SelectObject(hdc, oldFont);
g.ReleaseHDC(hdc);
}
void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr)
{
return;
}
const Theme& theme = SolarizedLight();
const float corner = 3.0f * scale;
UiFillRoundRect(g, rect, corner, theme.cardBg);
UiDrawRoundRect(g, rect, corner, theme.accent, 1.6f);
UiDrawEditFieldContent(g, rect, edit, font, scale);
}
void UiDrawEditFieldContent(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font,
float scale)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr)
{
return;
}
const Theme& theme = SolarizedLight();
const std::wstring& text = edit.Text();
const float innerLeft = rect.X + 6.0f * scale;
float innerWidth = 0.0f;
const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth);
const gd::RectF clipRect(innerLeft, rect.Y + 1.0f * scale, innerWidth, rect.Height - 2.0f * scale);
if (text.empty())
{
// 空内容仍绘制插入符,保持焦点可见。
UiDrawLine(g, textLeft, rect.Y + 4.0f * scale, textLeft, rect.Y + rect.Height - 4.0f * scale,
theme.accent, 1.4f * scale);
return;
}
// 光标与选区端点对应的像素位置(以文本左端为原点)。
const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret()));
const float selBeginX =
edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionBegin())) : 0.0f;
const float selEndX =
edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionEnd())) : 0.0f;
const bool fullSelection =
edit.HasSelection() && edit.SelectionBegin() == 0 && edit.SelectionEnd() == text.size();
const int saveState = g.Save();
g.SetClip(clipRect, gd::CombineModeIntersect);
// 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感。
// ⚠️ 这里**必须自己把矩形夹到内容区**:`UiFillRect` 对面积足够大的不透明矩形会走
// GDI `FillRect` 快路径,而 GDI 绘制**不受 GDI+ 裁剪区约束**,长文本的全选高亮
// (宽度等于整段文本宽度)因此会溢出到输入框之外。夹取之后矩形始终落在框内,
// 既保留快路径又不越界。
if (edit.HasSelection() && selEndX > selBeginX)
{
gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale, selEndX - selBeginX,
rect.Height - 6.0f * scale);
const float left = std::max(highlight.X, clipRect.X);
const float right = std::min(highlight.X + highlight.Width, clipRect.X + clipRect.Width);
const float top = std::max(highlight.Y, clipRect.Y);
const float bottom = std::min(highlight.Y + highlight.Height, clipRect.Y + clipRect.Height);
highlight = gd::RectF(left, top, right - left, bottom - top);
UiFillRect(g, highlight, theme.accent);
}
// 长文本用 ETO_CLIPPING 交给 GDI 裁剪,避免字符画到输入框之外。
UiTextAtClipped(g, text, font, textLeft, rect,
fullSelection ? theme.textOnAccent : theme.textStrong, clipRect);
if (!fullSelection && edit.HasSelection() && selEndX > selBeginX)
{
// 局部选区:在反白底衬上重绘被选中的片段,其余文字保持常规颜色。
const std::wstring selected =
text.substr(edit.SelectionBegin(), edit.SelectionEnd() - edit.SelectionBegin());
UiTextAtClipped(g, selected, font, textLeft + selBeginX, rect, theme.textOnAccent, clipRect);
}
// 插入符。
const float caretDrawX = textLeft + caretX;
UiDrawLine(g, caretDrawX, rect.Y + 4.0f * scale, caretDrawX, rect.Y + rect.Height - 4.0f * scale,
theme.textStrong, 1.4f * scale);
g.Restore(saveState);
}
bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y)
{
if (!edit.Active() || font == nullptr || rect.Width <= 0.0f)
{
return false;
}
if (!UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y)))
{
return false;
}
// 用与绘制完全相同的布局函数换算文本起点,保证点哪儿光标就落在哪儿。
float innerWidth = 0.0f;
const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth);
edit.BeginDrag(UiEditOffsetAt(font, edit.Text(), static_cast<float>(x) - textLeft));
return true;
}
bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x)
{
if (!edit.Active() || !edit.Dragging() || font == nullptr || rect.Width <= 0.0f)
{
return false;
}
float innerWidth = 0.0f;
const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth);
return edit.UpdateDrag(UiEditOffsetAt(font, edit.Text(), static_cast<float>(x) - textLeft));
}
bool UiSwatchNeedsOutline(float opacity)
{
return !(opacity >= kSwatchOutlineOpacity);
}
std::uint32_t UiDrawSwatch(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float opacity,
float radius, float scale)
{
double alpha = static_cast<double>(opacity);
if (!(alpha > 0.0))
{
alpha = 0.0;
}
if (alpha > 1.0)
{
alpha = 1.0;
}
const std::uint32_t filled = WithAlpha(color, static_cast<unsigned int>(alpha * 255.0 + 0.5));
if (rect.Width > 0.0f && rect.Height > 0.0f)
{
UiFillRoundRect(g, rect, radius, filled);
// 透明度过低时色块本身难以辨识,补充一圈描边以保持可读性;可见时不绘制描边。
if (UiSwatchNeedsOutline(opacity))
{
UiDrawRoundRect(g, rect, radius, SolarizedLight().borderStrong,
std::max(1.0f, scale));
}
}
return filled;
}
} // namespace jdi