#include "PaintUtil.hpp" #include #include namespace jdi { namespace { /** * @brief 颜色样块补充描边的不透明度阈值。 * * 不透明度低于该值时,色块自身与浅色背景的对比度已不足,需要额外描边才能辨识; * 高于该值时按需求不绘制描边,保持与预设色块一致的纯净外观。 */ const float kSwatchOutlineOpacity = 0.45f; /** * @brief 矩形面积超过该阈值(逻辑像素²)且为不透明色时,改用 GDI 填充。 * * GDI+ 的 FillRectangle 约 2.2ns/像素;面积过小时 GDI 的 GetHDC/ReleaseHDC * 固定开销反而不划算,因此在阈值以下仍走 GDI+。 */ const float kGdiFillAreaThreshold = 20000.0f; /** * @brief 构造圆角矩形路径。 * @param [IN,OUT] path 目标路径对象。 * @param [IN] rect 目标矩形。 * @param [IN] radius 圆角半径。 */ void BuildRoundRectPath(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(); } /** * @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(GetStockObject(DEFAULT_GUI_FONT)); } return font; } } bool UiFonts::Create(UINT targetDpi) { Destroy(); dpi = (targetDpi == 0) ? 96u : targetDpi; const double scale = static_cast(dpi) / 96.0; const int uiHeight = static_cast(std::lround(14.0 * scale)); const int smallHeight = static_cast(std::lround(12.0 * scale)); const int titleHeight = static_cast(std::lround(15.0 * scale)); const int monoHeight = static_cast(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); 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 && title != nullptr && mono != nullptr; } void UiFonts::Destroy() { const HFONT resources[] = {ui, uiBold, uiSmall, uiSmallBold, title, mono, monoSmall}; for (const HFONT font : resources) { if (font != nullptr) { DeleteObject(font); } } ui = nullptr; uiBold = nullptr; uiSmall = nullptr; uiSmallBold = 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(std::floor(rect.X)); rc.top = static_cast(std::floor(rect.Y)); rc.right = static_cast(std::ceil(rect.X + rect.Width)); rc.bottom = static_cast(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; BuildRoundRectPath(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; BuildRoundRectPath(path, rect, radius); gd::Pen pen(ToGdiColor(argb), width); g.DrawPath(&pen, &path); } 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(std::floor(rect.X)); rc.top = static_cast(std::floor(rect.Y)); rc.right = static_cast(std::ceil(rect.X + rect.Width)); rc.bottom = static_cast(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(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; } HDC hdc = GetDC(nullptr); if (hdc == nullptr) { return 0.0f; } HGDIOBJ oldFont = SelectObject(hdc, font); SIZE size = {}; GetTextExtentPoint32W(hdc, text.c_str(), static_cast(text.size()), &size); SelectObject(hdc, oldFont); ReleaseDC(nullptr, hdc); return static_cast(size.cx); } 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(x - trackRect.X) / static_cast(trackRect.Width); if (ratio < 0.0) { return 0.0; } if (ratio > 1.0) { return 1.0; } return ratio; } 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(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(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( std::floor(rect.Y + (rect.Height - static_cast(metrics.tmHeight)) * 0.5f)); RECT clip = {}; clip.left = static_cast(std::floor(clipRect.X)); clip.top = static_cast(std::floor(clipRect.Y)); clip.right = static_cast(std::ceil(clipRect.X + clipRect.Width)); clip.bottom = static_cast(std::ceil(clipRect.Y + clipRect.Height)); // ETO_CLIPPED 让 GDI 在指定矩形内裁剪,长文本不会再画到输入框之外。 ExtTextOutW(hdc, static_cast(std::floor(x)), baselineY, ETO_CLIPPED, &clip, text.c_str(), static_cast(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(x), static_cast(y))) { return false; } // 用与绘制完全相同的布局函数换算文本起点,保证点哪儿光标就落在哪儿。 float innerWidth = 0.0f; const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); edit.BeginDrag(UiEditOffsetAt(font, edit.Text(), static_cast(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(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(opacity); if (!(alpha > 0.0)) { alpha = 0.0; } if (alpha > 1.0) { alpha = 1.0; } const std::uint32_t filled = WithAlpha(color, static_cast(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