## 为什么
用户 2026-10-29 报告/要求五件事:
1. 派生信号的「时间轴偏移保留在样式上、其它调整烘值」维持现状(确认无需改动);
2. 信号名右键菜单里很多功能已被下方样式自定义区覆盖 ⇒ 只保留「仅显示该信号」,
并在它**上方**新增「克隆该信号」;
3. 设置菜单新增「主题配色」二级菜单(带 √ 状态),顺序 Light / Solarised Light /
Dark / Solarised Dark,默认主题维持现状;
4. 布局边界:中键平移视界时浮动面板上的边界**闪烁**;光标取值胶囊的**文字**框会**穿透**
到浮动面板上;并请核对网格内固定构件(图例 / Y 轴标尺 / GX 标签 / 回到最上方 /
浮动面板)的层级关系;
5. 单数据点信号在拖动光标时的**吸附**功能消失。
## 根因
### 布局边界层级(两个都很隐蔽)
- **文本层号写死**:文本是在绘制过程中由 `UiTextRectSink` 收集的,而宿主把所有文本都记在
固定层 2;组件几何层却是"组件表下标"(`PlotView` = 0)。面板层 = PlotView 层 + 1 = 1,
**低于**文本层 2 ⇒ 面板的遮挡矩形挖不到文本 ⇒ 胶囊文字画在面板表面。
- **面板像素缓存**:面板"先画进图层、命中缓存就贴图",贴图那一帧不画字 ⇒ 文本框凭空消失
⇒ 面板上的边界闪烁。
- 顺带发现 `PlotView::BuildBoxes` 用 `PushLayer()` 却**从不复位**,会把宿主后续所有组件的
层号整体推高。
### 单点信号吸附
`MinorCellSparse` 在"把判定范围收窄到数据实际时间跨度"的分支里有
`if (!(dataMax > dataMin)) return false;`。单个有效采样点时 `dataMax == dataMin`,
于是被判定成"不稀疏" ⇒ 不吸附。
## 改了什么
### 1. 布局边界层级
- `UiTextRectSink` 新增 `OnTextLayer(int)` / `TextLayer()`;新增 `UiSetTextLayer` /
`UiGetTextLayer`;`UiBoxTable` 实现为改 `m_layer`,并暴露 `Layer()`。
- `Application::Render` 在绘制每个组件前下发 `UiSetTextLayer(ComponentLayer(&component))`,
浮层用 `OverlayLayer(1/2)`;新增 `ComponentLayer()` / `OverlayLayer()` /
`kLayersPerComponent = 3`,与 `PaintLayoutBounds` 的层号**完全同源**。
- `PaintLayoutBounds` 改为**显式** `SetLayer(下标 × 3)`(不再依赖 `PushLayer` 累加)。
- `PlotView`:`BuildBoxes` 用 `baseLayer + 1` 放浮动面板(并登记遮挡)、
`baseLayer + 2` 放绘制在面板之上的滚动条 / 回到最上方 / 输入框,结束**复位**层号;
`paintOverlays` 同步分两次抬高文本层。
- 面板像素图层新增 `PanelSlot::textRects` + 转发型收集器 `PanelTextCollector`:
渲染时存文本框,命中缓存贴图时回放 ⇒ 几何与缓存命中无关(消除闪烁)。
### 2. 信号右键菜单 + 克隆
- 菜单只剩:标题、**克隆该信号**、**仅显示该信号**;删除显示/隐藏、显示全部/隐藏全部、
预设色块行、样式三项,以及对应的 `CMD_SIGNAL_TOGGLE/SHOW_ALL/HIDE_ALL`、
`CMD_COLOR_BASE`、`CMD_MODE_BASE` 及其分支(死代码)。
- 新增 `CMD_SIGNAL_CLONE`(post `WM_APP + 5`)→ `Application::CloneSignal`:
把「调整」(系数 / 绝对值 / 镜像 / Y 偏移)烘进采样值、**时间轴偏移保留在样式上**,
名称前置 `*`,插入到源信号**正下方**,并**保留原外观**(颜色/透明度/线宽/绘制样式)。
- 抽出 `Application::InsertSignalBelowSource(...)` 供派生与克隆共用。
### 3. 主题配色
- `ThemeKind{LIGHT, SOLARIZED_LIGHT, DARK, SOLARIZED_DARK}` + `ThemeFor()` / `ThemeName()`;
Light 取 VS Code 2026 默认浅色、Dark 取 Dark+、Solarized Dark 取 VS Code solarized dark。
**默认仍是 Solarised Light**。
- 新增 `CurrentTheme()` / `SetCurrentTheme()`:宿主交出自己的 `m_theme` 地址,
切换主题只改内容;`UiControls` 里没有 `Theme` 形参的原语改用它(原来硬编码
`SolarizedLight()`,换主题不生效)。
- `Application::ApplyTheme()`:改配色 → `PlotView::OnThemeChanged()`(作废场景/宽基底/
装饰/光晕/面板图层)+ `SignalPanel::OnThemeChanged()`(作废行位图)→ 整窗重绘。
- 菜单用 `SUBMENU` + 4 个 `keepOpen` 子项;新增 `PopupMenu::UpdateSubmenuItemLabel()`
回写勾选,并让子菜单分支尊重 `keepOpen`(原为无条件关闭)。
### 4. 单点信号吸附
`MinorCellSparse` 改为只在 `dataMax < dataMin` 时放弃;相等时按"那一个小格"判定
(`need = 5 > finiteCount = 1` ⇒ 稀疏 ⇒ 吸附恢复)。
### 5. 审计
`Makefile` 的 `LAYOUT_BOUNDS` / `JDI_LAYOUT_BOUNDS` **从未被任何 `#if` 引用**
(`LAYOUT_BOUNDS=0` 产物字节数完全相同),属名实不符的死开关 ⇒ 删除并注明原因。
## 怎么验证
新增两支离屏探针(不占鼠标 / 键盘 / 屏幕):
```
.preflight/round15check 28 passed, 0 failed
单点吸附 7 条(含"远处不吸附")、面板层级 5 条、面板文本框跨帧与中键平移稳定 2 条、
克隆 14 条(烘值逐点比对 / 名称前缀 / 偏移与外观保留 / 调整复位)
.preflight/themecheck 17 passed, 0 failed
4 套主题可读性与可区分、默认 = Solarised Light、逐主题换色且无旧底色残留、
切回默认逐像素还原(diff = 0)、子菜单 4 项 / 顺序 / 唯一勾选 / 命令切换 / 菜单保持打开
```
回归全绿:
```
uicheck 69/69 lbcheck 21/21 lbresid 5/5 lbzorder 4/4
lbcovr 7/7 lbpanel 10/10 derivecheck 16/16
rendercheck: 8 个场景哈希与上一轮逐一相同 + FUNC 7/7, exit=0
A FAED59DF284153DB B E6443BFE4E3955F8 C 96A0AA8C402F1729 D E3D5DBD998E43720
E DA0583CB13C26A6F F FFB822159F604D1D G 19F8DE8FE3A8DF18 H DBBDDE4CB2C97C92
```
`make release` / `make debug` 零 error / 零 warning;产物 **791,552 B**
(单一 exe,未拆 DLL,未改 `-Oz`;离 1MB 预警线约 250 KB)。
680 lines
20 KiB
C++
680 lines
20 KiB
C++
#include "PaintUtil.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cstddef>
|
||
#include <functional>
|
||
#include <mutex>
|
||
#include <unordered_map>
|
||
#include <utility>
|
||
#include <string>
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
namespace
|
||
{
|
||
/**
|
||
* @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;
|
||
|
||
/** 文本的像素宽度与字符单元高度(后者与 `DT_VCENTER` 的居中口径一致)。 */
|
||
struct TextExtent
|
||
{
|
||
float width = 0.0f; /**< 像素宽度。 */
|
||
float height = 0.0f; /**< 字符单元高度。 */
|
||
};
|
||
|
||
std::unordered_map<MeasureKey, TextExtent, MeasureKeyHash> g_measureCache;
|
||
std::mutex g_measureMutex;
|
||
|
||
/**
|
||
* @brief 取文本的宽 / 高(带缓存)。
|
||
*
|
||
* ⚠️ 必须走 GDI(`GetDC` + `GetTextExtentPoint32W` + `ReleaseDC`),单次约 10µs;
|
||
* 而布局与绘制每帧会把同一批字符串**重复测量成百上千次**,因此按 (字体, 文本) 缓存。
|
||
* 高度也一并缓存:`DrawUiText` 的快速路径要用它做垂直居中(见那里的说明)。
|
||
*/
|
||
TextExtent MeasureText(HFONT font, const std::wstring& text)
|
||
{
|
||
if (text.empty() || font == nullptr)
|
||
{
|
||
return TextExtent();
|
||
}
|
||
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 TextExtent();
|
||
}
|
||
HGDIOBJ oldFont = SelectObject(hdc, font);
|
||
SIZE size = {};
|
||
GetTextExtentPoint32W(hdc, text.c_str(), static_cast<int>(text.size()), &size);
|
||
SelectObject(hdc, oldFont);
|
||
ReleaseDC(nullptr, hdc);
|
||
|
||
TextExtent extent;
|
||
extent.width = static_cast<float>(size.cx);
|
||
extent.height = static_cast<float>(size.cy);
|
||
{
|
||
std::lock_guard<std::mutex> lock(g_measureMutex);
|
||
if (g_measureCache.size() >= kMeasureCacheLimit)
|
||
{
|
||
g_measureCache.clear();
|
||
}
|
||
g_measureCache.emplace(key, extent);
|
||
}
|
||
return extent;
|
||
}
|
||
} // 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);
|
||
}
|
||
|
||
namespace
|
||
{
|
||
/** @brief 当前安装的文本几何收集器(`nullptr` = 不收集,调试开关关闭)。 */
|
||
UiTextRectSink* g_textRectSink = nullptr;
|
||
/** @brief 当前文本矩形的坐标偏移(离屏位图渲染时非零)。 */
|
||
float g_textRectOffsetX = 0.0f;
|
||
float g_textRectOffsetY = 0.0f;
|
||
|
||
/**
|
||
* @brief 回报一段文本的墨迹矩形(仅在收集器已安装时执行)。
|
||
*
|
||
* 入参 `rect` 是**布局矩形**(可能整行宽);这里按对齐方式与实测宽度/高度把它
|
||
* 收窄成文字真正占据的那一块 —— 否则每个文本 box 都会跟整行一样宽,边界失去意义。
|
||
*
|
||
* @param [IN] text 文本内容。
|
||
* @param [IN] font 字体句柄。
|
||
* @param [IN] rect 布局矩形。
|
||
* @param [IN] align 水平对齐方式。
|
||
*/
|
||
void ReportTextRect(const std::wstring& text, HFONT font, const gd::RectF& rect, TextAlign align)
|
||
{
|
||
if (g_textRectSink == nullptr || text.empty() || font == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const TextExtent extent = MeasureText(font, text);
|
||
if (extent.width <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
const float width = std::min(extent.width, rect.Width);
|
||
const float height = std::min(std::max(extent.height, 1.0f), rect.Height);
|
||
float x = rect.X;
|
||
if (align == TextAlign::CENTER)
|
||
{
|
||
x = rect.X + (rect.Width - width) * 0.5f;
|
||
}
|
||
else if (align == TextAlign::RIGHT)
|
||
{
|
||
x = rect.X + rect.Width - width;
|
||
}
|
||
const float y = rect.Y + (rect.Height - height) * 0.5f;
|
||
g_textRectSink->OnTextRect(
|
||
gd::RectF(x + g_textRectOffsetX, y + g_textRectOffsetY, width, height));
|
||
}
|
||
|
||
/**
|
||
* @brief `UiText` 与 `UiTextClipped` 的公共实现。
|
||
*
|
||
* ⚠️ `clip == nullptr` 时**不加任何 GDI 裁剪**(与历史行为完全一致);
|
||
* 提供裁剪时在同一个 HDC 上 `SaveDC`/`IntersectClipRect` 并完整还原 ——
|
||
* 这是唯一能让 `DrawTextW` 受限于指定范围的办法(GDI+ 的 `SetClip` 对 GDI 文本无效)。
|
||
*
|
||
* @param [IN,OUT] g 绘图上下文。
|
||
* @param [IN] text 文本内容。
|
||
* @param [IN] font 字体句柄。
|
||
* @param [IN] rect 文本布局矩形。
|
||
* @param [IN] argb 文本颜色。
|
||
* @param [IN] align 水平对齐方式。
|
||
* @param [IN] clip 裁剪矩形(nullptr = 不裁剪)。
|
||
*/
|
||
void DrawUiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect,
|
||
std::uint32_t argb, TextAlign align, const gd::RectF* clip)
|
||
{
|
||
if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
if (clip != nullptr && (clip->Width <= 0.0f || clip->Height <= 0.0f))
|
||
{
|
||
return; // 裁剪区为空:任何文字都落在界外
|
||
}
|
||
|
||
HDC hdc = g.GetHDC();
|
||
if (hdc == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// GDI 文本不受 GDI+ 裁剪区约束:需要限制范围时显式裁剪,结束前完整还原 DC 状态。
|
||
int saved = 0;
|
||
if (clip != nullptr)
|
||
{
|
||
saved = SaveDC(hdc);
|
||
if (saved != 0)
|
||
{
|
||
IntersectClipRect(hdc, static_cast<int>(std::floor(clip->X)),
|
||
static_cast<int>(std::floor(clip->Y)),
|
||
static_cast<int>(std::ceil(clip->X + clip->Width)),
|
||
static_cast<int>(std::ceil(clip->Y + clip->Height)));
|
||
}
|
||
}
|
||
|
||
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));
|
||
|
||
// ⚠️ 这里必须继续用 `DrawTextW`:试过改用 `ExtTextOutW` 的"快速路径"(约快 2~4 倍),
|
||
// 但同一段文字的**光栅化结果并不相同**(实测面板区域 10% 的像素有差异,
|
||
// 亚像素边缘与基线差 1px)—— 全体界面文字观感都会变,因此放弃。
|
||
// 列表卡顿改由"行位图缓存 + 动画期间不预热网格图层"解决(见 `SignalPanel::PaintRowCached`)。
|
||
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);
|
||
|
||
// 调试:回报文字真正占据的矩形(在恢复 DC 状态之前算,此时字体仍被选中)。
|
||
ReportTextRect(text, font, rect, align);
|
||
|
||
SetTextColor(hdc, oldColor);
|
||
SetBkMode(hdc, oldMode);
|
||
SelectObject(hdc, oldFont);
|
||
if (saved != 0)
|
||
{
|
||
RestoreDC(hdc, saved);
|
||
}
|
||
g.ReleaseHDC(hdc);
|
||
}
|
||
} // namespace
|
||
|
||
void UiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb,
|
||
TextAlign align)
|
||
{
|
||
DrawUiText(g, text, font, rect, argb, align, nullptr);
|
||
}
|
||
|
||
void UiTextClipped(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect,
|
||
std::uint32_t argb, TextAlign align, const gd::RectF& clipRect)
|
||
{
|
||
DrawUiText(g, text, font, rect, argb, align, &clipRect);
|
||
}
|
||
|
||
float UiMeasureTextWidth(HFONT font, const std::wstring& text)
|
||
{
|
||
// 记忆化(键 = 字体 + 文本,值 = 宽 / 高):布局代码每帧会把同一批字符串
|
||
// **重复测量成百上千次**,而 `GetTextExtentPoint32W` 单次约 10µs。
|
||
return MeasureText(font, text).width;
|
||
}
|
||
|
||
float UiMeasureTextHeight(HFONT font, const std::wstring& text)
|
||
{
|
||
return MeasureText(font, text).height;
|
||
}
|
||
|
||
void UiSetTextRectSink(UiTextRectSink* sink)
|
||
{
|
||
g_textRectSink = sink;
|
||
}
|
||
|
||
UiTextRectSink* UiGetTextRectSink()
|
||
{
|
||
return g_textRectSink;
|
||
}
|
||
|
||
void UiSetTextLayer(int layer)
|
||
{
|
||
if (g_textRectSink != nullptr)
|
||
{
|
||
g_textRectSink->OnTextLayer(layer);
|
||
}
|
||
}
|
||
|
||
int UiGetTextLayer()
|
||
{
|
||
return (g_textRectSink != nullptr) ? g_textRectSink->TextLayer() : 0;
|
||
}
|
||
|
||
void UiSetTextRectOffset(float dx, float dy)
|
||
{
|
||
g_textRectOffsetX = dx;
|
||
g_textRectOffsetY = dy;
|
||
}
|
||
|
||
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 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);
|
||
|
||
// 调试:回报文字真正占据的矩形(起点由调用方给出,宽度取实测值)。
|
||
if (g_textRectSink != nullptr)
|
||
{
|
||
const float width = UiMeasureTextWidth(font, text);
|
||
if (width > 0.0f)
|
||
{
|
||
const float height = std::max(1.0f, static_cast<float>(metrics.tmHeight));
|
||
g_textRectSink->OnTextRect(
|
||
gd::RectF(std::floor(x) + g_textRectOffsetX,
|
||
rect.Y + (rect.Height - height) * 0.5f + g_textRectOffsetY, width, height));
|
||
}
|
||
}
|
||
|
||
SetTextColor(hdc, oldColor);
|
||
SetBkMode(hdc, oldMode);
|
||
SelectObject(hdc, oldFont);
|
||
g.ReleaseHDC(hdc);
|
||
}
|
||
|
||
} // namespace jdi
|