Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelPaint.cpp
T
DeepSeek V4.1 Flash aa4925a2b7 fix(UI): 边界框不再内缩 + 文本获得几何身份 + 补全漏项 + 描边提速 32%
## 1. 取消内缩(这是"边框不清晰"的主因)

第九轮为"父子矩形共边不叠粗"给 depth > 0 的 box 加了 1px 内缩。结果 GX 徽标
(30x17 逻辑像素的胶囊,圆角 = 高的一半,外接矩形正好等于 LaneBadgeRect)内缩 1px 后
框比形状小一圈 ⇒ 左右尖端与上下圆弧全部落在框外。

现行规则:**框 = 可见形状的外接矩形,绝不内缩**。父子共边时让"后画的覆盖"即可,
这本来就是正确的图层语义。层级 > 0 只影响配色,不再影响几何。

## 2. 文本第一次有了几何身份(唯一出口回报,不逐处登记)

文本是本项目唯一"没有几何身份"的元素:65 处绘制点各自现算 gd::RectF、画完即弃。
逐处补登记必漏,因此在唯一出口回报一次:

  PaintUtil 新增
    class UiTextRectSink { virtual void OnTextRect(const gd::RectF&) = 0; };
    void UiSetTextRectSink(UiTextRectSink*);       // 开关关闭时为 nullptr
    void UiSetTextRectOffset(float dx, float dy);  // 离屏位图渲染的坐标换算
    UiTextRectSink* UiGetTextRectSink();

DrawUiText() / UiTextAtClipped() 画完后按对齐方式 + 实测宽高把布局矩形**收窄成墨迹矩形**
再回报。UiDebugBoxList 直接继承该接口,宿主只需 UiSetTextRectSink(&m_debugBoxes) 一句。
开关关闭时只是一次空指针判断,正常绘制零影响。

## 3. 缓存命中的行也要有文本框

面板的行是"先画进位图再贴回",命中缓存的帧**根本不调用 UiText** ⇒ 文本框第二帧起消失。
做法:行渲染时用本地 RowTextCollector 收集(位图坐标)→ 连同位图存进
RowBitmapCache::textRects → 贴图时按 (originX, originY) 回放。
⚠️ **必须无条件收集**:只在"外层有收集器"时收集会导致"关调试时滚过列表 → 打开调试 →
命中缓存 → 永远没有文本框"。

## 4. 补齐 PlotView 漏项(全部读现成成员)

m_timeAxisRect / m_scrollBarRect / BackToTopRect() / m_cursorSpanRect /
m_cursorLabelRects / EditFieldRect()

## 5. 描边提速:图元 12 → 8

微基准结论:图元数量是唯一瓶颈(每次 FillRectangle ≈1µs)。
旧实现把每条边拆成"中段 1px + 两端各一段加粗"= 12 次;新实现 = 4 条 1px 边 + 4 个自适应角块。
实测 paintBoxes 5.892ms → 4.028ms(-32%),观感几乎一致(角仍是两条粗边的交点)。
⚠️ 不要改写成 GraphicsPath/FillPath(单次约 20µs,比 12 段还慢);
GDI PolyPolygon 一次画 5688 个矩形要 114ms,更不能用。

## 6. 关闭开关时清空容器(释放内存,避免误读)

## 7. 验证

- make release / make debug 零 error 零 warning;TargetCheck.exe 909,824 B(888.5 KiB < 900 KiB)。
- 新增探针 .preflight/lbcovr 7/7:box-time-axis / box-plot-scrollbar / box-back-to-top /
  box-gx-badge-matches-pill(容差 0.5px)/ text-boxes-collected(89 个)/
  row-text-boxes-from-cached-rows / boxes-cleared-when-off。
- 回归:lbcheck 21/21、lbresid 5/5、lbzorder 4/4、uicheck 69/69。
- rendercheck:8 个场景的整帧与区域哈希与上一轮**逐一相同** + FUNC 7/7
  (证明调试功能对正常绘制零影响)。
- 帧耗时分解(MF4 / 283 网格):base 9.06ms、仅强制整帧 6.66ms、完整调试 11.35ms;
  collect 0.082ms、paintBoxes 4.028ms(上一轮 5.892ms)。

## 8. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第九阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第十一轮」硬约束与踩坑。
2026-10-28 17:38:55 +08:00

1006 lines
41 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.
// SignalPanel 实现分片 —— 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条。
//
// 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
// 六个分片按**职责**划分:
// SignalPanel.cpp 核心:状态、行集合、布局与几何、可见性
// SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条(**本文件**)
// SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状
// SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度
// SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号"
// SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执
#include "SignalPanelInternal.hpp"
#include <cstring>
namespace jdi
{
namespace
{
/**
* @brief 行位图渲染时使用的**临时**文本几何收集器(调试用)。
*
* 行的内容先画进位图再贴回屏幕,因此这里收到的是**位图坐标**;
* `PaintRowCached` 会把它们连同位图一起缓存,并在贴图时按原点偏移回放给外层收集器。
* ⚠️ 不缓存的话,"命中缓存的帧根本不调用 `UiText`",文本框会在第二帧全部消失。
*/
class RowTextCollector : public UiTextRectSink
{
public:
void OnTextRect(const gd::RectF& rect) override { rects.push_back(rect); }
std::vector<gd::RectF> rects; /**< 位图坐标下的文本墨迹矩形。 */
};
} // namespace
void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row)
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
{
return;
}
const int rowCount = static_cast<int>(m_rows.size());
const float height = m_rows[static_cast<std::size_t>(row)].height;
const float naturalY = RowRect(row).Y;
const float stickyY = StickyGroupRowTop(row, rowCount);
if (stickyY + height <= m_listRect.Y + 0.5f)
{
return; // 已经整块滑出列表上边界
}
m_rowYOverrideActive = true;
m_rowYOverrideY = stickyY;
// 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清;
// `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。
SetRowClip(gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height));
FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg);
PaintGroupRow(g, row);
ClearRowClip();
// 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。
if (stickyY > naturalY + 0.5f)
{
UiDrawLine(g, m_listRect.X, stickyY + height - 0.5f, m_listRect.X + m_listRect.Width,
stickyY + height - 0.5f, m_theme->border, 1.0f);
}
m_rowYOverrideActive = false;
}
void SignalPanel::Paint(gd::Graphics& g)
{
if (m_theme == nullptr || m_fonts == nullptr)
{
return;
}
EnsureRows();
UiFillRect(g, m_area, m_theme->panelBg);
// 标题区。
{
std::wstring title = L"信号";
if (m_dataSet != nullptr && m_tab != nullptr)
{
title += L" · " + std::to_wstring(m_tab->VisibleCount()) + L"/" +
std::to_wstring(TotalSignalCount()) + L" 已选";
}
gd::RectF titleRect(m_headerRect.X + kPadding * m_scale, m_headerRect.Y,
std::max(20.0f, m_headerRect.Width - kPadding * 2.0f * m_scale -
m_toggleAllRect.Width - 8.0f * m_scale),
m_headerRect.Height);
UiText(g, UiEllipsize(title, m_fonts->uiBold, titleRect.Width), m_fonts->uiBold, titleRect,
m_theme->textStrong, TextAlign::LEFT);
const bool allVisible = AreAllSignalsVisible();
const std::wstring toggleLabel = allVisible ? L"全部隐藏" : L"全部显示";
const bool toggleHover = (m_hoverStyle == StyleHit::TOGGLE_ALL);
if (toggleHover)
{
UiFillRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->hover);
}
UiDrawRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->border, 1.0f);
const bool enabled = (m_dataSet != nullptr && !m_dataSet->Empty());
UiText(g, toggleLabel, m_fonts->uiSmall, m_toggleAllRect,
enabled ? m_theme->text : m_theme->textMuted, TextAlign::CENTER);
}
// 「筛选信号」输入框:未输入内容时显示占位提示,单击进入编辑,输入实时过滤下方列表。
{
UiFillRoundRect(g, m_filterRect, kCorner * m_scale, m_theme->cardBg);
UiDrawRoundRect(g, m_filterRect, kCorner * m_scale,
m_filterEditing ? m_theme->accent : m_theme->border,
m_filterEditing ? 1.6f : 1.0f);
const float pad = kFilterTextPad * m_scale;
const gd::RectF clearRect = FilterClearRect();
// 有输入内容时在右侧留出 ✕ 的位置,文本不会压到按钮上。
const float textRight = (clearRect.Width > 0.0f) ? clearRect.X : (m_filterRect.X + m_filterRect.Width);
const float textWidth = std::max(8.0f, textRight - pad - (m_filterRect.X + pad));
const gd::RectF textRect(m_filterRect.X + pad, m_filterRect.Y, textWidth,
m_filterRect.Height);
if (m_filterEditing)
{
// 外框(m_filterRect)已经画好了底板与强调色边框,这里只叠加文本内容。
// 若改用 UiDrawEditField,会在外框内部再套一个带边框的圆角框,
// 空内容时只剩一圈内框十分突兀(用户明确要求去掉这层"内圈")。
gd::RectF fieldRect = FilterFieldRect();
fieldRect.Width = std::max(8.0f, textWidth + pad);
UiDrawEditFieldContent(g, fieldRect, m_filterEdit, m_fonts->uiSmall, m_scale);
}
else if (m_filterText.empty())
{
UiText(g, L"筛选信号", m_fonts->uiSmall, textRect, m_theme->textMuted, TextAlign::LEFT);
}
else
{
UiText(g, UiEllipsize(m_filterText, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
textRect, m_theme->text, TextAlign::LEFT);
}
// 清空按钮 ✕:仅在筛选框有内容时出现,悬停高亮(鼠标光标保持箭头)。
if (clearRect.Width > 0.0f)
{
const bool clearHot = (m_hoverStyle == StyleHit::FILTER_CLEAR);
if (clearHot)
{
UiFillRoundRect(g, clearRect, 3.0f * m_scale, WithAlpha(m_theme->danger, 0x33));
}
const std::uint32_t crossColor = clearHot ? m_theme->danger : m_theme->textMuted;
const float crossPad = clearRect.Width * 0.28f;
UiDrawLine(g, clearRect.X + crossPad, clearRect.Y + crossPad,
clearRect.X + clearRect.Width - crossPad,
clearRect.Y + clearRect.Height - crossPad, crossColor, 1.5f * m_scale);
UiDrawLine(g, clearRect.X + clearRect.Width - crossPad, clearRect.Y + crossPad,
clearRect.X + crossPad, clearRect.Y + clearRect.Height - crossPad, crossColor,
1.5f * m_scale);
}
}
// 列表区。
{
const int saveState = g.Save();
g.SetClip(gd::RectF(m_listRect.X, m_listRect.Y, m_listRect.Width, m_listRect.Height));
if (m_dataSet == nullptr || m_dataSet->Empty())
{
UiText(g, L"尚未加载数据,请打开数据文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
TextAlign::CENTER);
}
else if (m_rows.empty() && !m_filterText.empty())
{
// 筛选结果为空:明确提示"无匹配",而不是让人误以为数据丢失。
UiText(g, L"无匹配的筛选信号", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
TextAlign::CENTER);
}
else
{
const int rowCount = static_cast<int>(m_rows.size());
// 先定出要「悬浮」的分组行(若有):常规循环跳过它,最后统一钉在列表顶部绘制,
// 否则它会先被画一次在滚动位置上、又被画一次在顶部(视觉上重叠两份)。
const int stickyRow = StickyGroupRowIndex(rowCount);
for (int row = 0; row < rowCount; ++row)
{
if (row == stickyRow)
{
continue;
}
const gd::RectF rect = RowRect(row);
if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height)
{
continue;
}
// 折起/展开动画:信号行整组向上平移,越过组名下边界的部分要“消失(藏进组名栏)”。
// 已经完全越过的行整行跳过(省一次绘制),部分越过的行把裁剪上边界抬到组名下边界;
// ⚠️ 此时还必须把 **GDI+ 裁剪**也收紧到同一条线 —— 行内的色块 / 复选框 / 网格徽标
// 走的是 GDI+ 绘制(只受 `SetClip` 约束),不收紧就会画到组名栏上面去。
const int rowSave = g.Save();
SetRowClip(rect);
const float hideTop = AnimationHideTopForRow(row, stickyRow);
if (hideTop > rect.Y + 0.5f)
{
if (rect.Y + rect.Height <= hideTop + 0.5f)
{
g.Restore(rowSave);
ClearRowClip();
continue;
}
ClampRowClipTop(hideTop);
g.SetClip(m_rowClipRect, gd::CombineModeIntersect);
}
// ⚠️ 整行走**位图缓存**:分组折起/展开动画只是在平移行,
// 行的像素一个也没变;逐行重画十来个 GDI+ 原语要 0.35ms/行
//(一屏 35~40 行 ⇒ 每帧 12~15ms),这是"只有文字的列表却滚动卡顿"的真因。
PaintRowCached(g, row);
g.Restore(rowSave);
ClearRowClip();
}
PaintStickyGroupRow(g, stickyRow);
}
g.Restore(saveState);
}
PaintScrollBar(g);
PaintStyleSection(g);
// 分隔线。
UiDrawLine(g, m_area.X + m_area.Width - 1.0f, m_area.Y, m_area.X + m_area.Width - 1.0f,
m_area.Y + m_area.Height, m_theme->border, 1.0f);
UiDrawLine(g, m_headerRect.X, m_headerRect.Y + m_headerRect.Height - 1.0f,
m_headerRect.X + m_headerRect.Width, m_headerRect.Y + m_headerRect.Height - 1.0f,
m_theme->border, 1.0f);
if (m_styleRect.Height > 0.0f)
{
UiDrawLine(g, m_styleRect.X, m_styleRect.Y, m_styleRect.X + m_styleRect.Width, m_styleRect.Y,
m_theme->border, 1.0f);
}
}
void SignalPanel::PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font,
const gd::RectF& rect, std::uint32_t argb, TextAlign align)
{
// 行内文本必须显式裁剪:GDI 文本不遵循 GDI+ 的 `SetClip`,
// 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。
// 另外,分组折起动画是**刚性平移**(行高不变,信号行整体上移并被组名栏裁掉)——
// 此时同样必须裁到**本行矩形**内,否则垂直居中的文字会溢出到相邻行上。
//
// ⚠️ **行位图缓存渲染时反而不能裁剪**(`m_rowClipRender`):位图是整行内容,
// 裁剪留到贴图时由 GDI+ 裁剪区完成(否则部分滚出上边界的行会永久丢掉顶部像素)。
if (m_rowClipRender)
{
UiText(g, text, font, rect, argb, align);
return;
}
UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect);
}
void SignalPanel::FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb)
{
// 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效
//(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。
gd::RectF clipped = rect;
if (!m_rowClipRender)
{
clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect);
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
{
return;
}
}
UiFillRect(g, clipped, argb);
}
void SignalPanel::InvalidateRowCache()
{
for (RowBitmapCache& cache : m_rowBitmapCache)
{
if (cache.dc != nullptr)
{
DeleteDC(cache.dc);
cache.dc = nullptr;
}
if (cache.dib != nullptr)
{
DeleteObject(cache.dib);
cache.dib = nullptr;
}
}
m_rowBitmapCache.clear();
m_rowBitmapLiveCount = 0;
}
std::uint64_t SignalPanel::RowCacheKey(int row) const
{
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash ^= value;
hash *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
const auto mixText = [&mix](const std::wstring& text)
{
mix(text.size());
for (const wchar_t character : text)
{
mix(static_cast<std::uint64_t>(character));
}
};
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return hash;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
mix(static_cast<std::uint64_t>(item.kind));
mix(static_cast<std::uint64_t>(item.groupIndex + 1));
mix(static_cast<std::uint64_t>(item.signalIndex + 1));
mixFloat(item.height);
mixFloat(m_scale);
mix(static_cast<std::uint64_t>(reinterpret_cast<std::uintptr_t>(m_theme)));
mix(static_cast<std::uint64_t>(reinterpret_cast<std::uintptr_t>(m_fonts)));
mix(static_cast<std::uint64_t>((m_hoverRow == row) ? (static_cast<int>(m_hoverPart) + 1) : 0));
if (item.kind == RowKind::GROUP)
{
const SignalGroup* group =
(m_dataSet != nullptr) ? m_dataSet->GroupAt(item.groupIndex) : nullptr;
mixText((group != nullptr) ? group->name : std::wstring());
mixText(GroupStatsText(row));
mix(IsGroupCollapsed(item.groupIndex) ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(item.displayCount + 1));
if (m_tab != nullptr)
{
mix(m_tab->GroupAllVisible(item.groupIndex) ? 1ull : 0ull);
mix(m_tab->GroupHasVisible(item.groupIndex) ? 2ull : 0ull);
}
const bool editing = (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row);
mix(editing ? 1ull : 0ull);
if (editing)
{
mixText(m_edit.Text());
// ⚠️ 插入符与选区必须进键:行位图缓存只按“行的可见内容”生效,
// 而编辑态下插入符位置 / 选区范围也是可见内容。漏掉它们时,
// 方向键与鼠标框选虽然改变了 `TextEdit` 状态,但键不变 ⇒ 贴回的仍是旧位图,
// 屏幕上看就是“无法部分选择、光标不动”(用户 2026-10-19 报告)。
mix(static_cast<std::uint64_t>(m_edit.Caret()) + 1ull);
mix(static_cast<std::uint64_t>(m_edit.SelectionBegin()) + 1ull);
mix(static_cast<std::uint64_t>(m_edit.SelectionEnd()) + 1ull);
}
return hash;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
const SignalData* signal = (m_dataSet != nullptr) ? m_dataSet->Resolve(ref) : nullptr;
const SignalStyle* style = StyleOf(ref);
mixText((signal != nullptr) ? signal->name : std::wstring());
mix((SelectedRef() == ref) ? 1ull : 0ull);
if (style != nullptr)
{
mix(static_cast<std::uint64_t>(style->color));
mixFloat(style->opacity);
}
if (m_tab != nullptr)
{
mix(m_tab->IsVisible(ref) ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(m_tab->LaneOfSignal(ref) + 1));
}
const bool editing = (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row);
mix(editing ? 1ull : 0ull);
if (editing)
{
mixText(m_edit.Text());
// 同分组行:插入符 / 选区也是行的可见内容(见 `GROUP_TITLE` 分支的注释)。
mix(static_cast<std::uint64_t>(m_edit.Caret()) + 1ull);
mix(static_cast<std::uint64_t>(m_edit.SelectionBegin()) + 1ull);
mix(static_cast<std::uint64_t>(m_edit.SelectionEnd()) + 1ull);
}
return hash;
}
void SignalPanel::PaintRowCached(gd::Graphics& g, int row)
{
if (m_dataSet == nullptr || m_tab == nullptr || m_theme == nullptr || row < 0 ||
row >= static_cast<int>(m_rows.size()))
{
return;
}
const gd::RectF rect = RowRect(row);
if (rect.Height <= 0.5f)
{
return;
}
const int originX = static_cast<int>(std::floor(m_listRect.X));
const int originY = static_cast<int>(std::floor(rect.Y));
const int width = static_cast<int>(std::ceil(m_listRect.Width));
const int height = static_cast<int>(std::ceil(rect.Height));
if (width <= 0 || height <= 0)
{
return;
}
// ⚠️ 上限检查必须在**取引用之前**:`InvalidateRowCache()` 会清空容器,
// 清理后原有引用(`RowBitmapCache&`)就悬空了。
if (m_rowBitmapLiveCount >= kRowBitmapCacheLimit)
{
InvalidateRowCache();
}
if (m_rowBitmapCache.size() < m_rows.size())
{
m_rowBitmapCache.resize(m_rows.size());
}
RowBitmapCache& cache = m_rowBitmapCache[static_cast<std::size_t>(row)];
const std::uint64_t key = RowCacheKey(row);
const bool sameSize = (cache.dc != nullptr) && cache.width == width && cache.height == height;
if (!sameSize || cache.key != key)
{
if (!sameSize)
{
if (cache.dc != nullptr)
{
DeleteDC(cache.dc);
cache.dc = nullptr;
if (m_rowBitmapLiveCount > 0)
{
--m_rowBitmapLiveCount;
}
}
if (cache.dib != nullptr)
{
DeleteObject(cache.dib);
cache.dib = nullptr;
}
// ⚠️ DIB + 内存 DC(而不是 `gd::Bitmap`):GDI 在 GDI+ 位图的临时 HDC 上
// 会关闭 ClearType,导致列表文字丢失亚像素渲染、与正文观感不一致。
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width;
info.bmiHeader.biHeight = -height; // 自上而下
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HDC memoryDc = CreateCompatibleDC(nullptr);
HBITMAP dib = (memoryDc != nullptr)
? CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0)
: nullptr;
if (memoryDc == nullptr || dib == nullptr)
{
if (dib != nullptr)
{
DeleteObject(dib);
}
if (memoryDc != nullptr)
{
DeleteDC(memoryDc);
}
return;
}
SelectObject(memoryDc, dib);
cache.dc = memoryDc;
cache.dib = dib;
cache.width = width;
cache.height = height;
++m_rowBitmapLiveCount;
}
{
gd::Graphics rowGraphics(cache.dc);
rowGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias);
rowGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
rowGraphics.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
rowGraphics.Clear(ToGdiColor(m_theme->panelBg));
// ⚠️ 用**几何平移**而不是 `TranslateTransform`(见 `m_rowRenderActive`)。
m_rowRenderActive = true;
m_rowRenderOriginX = static_cast<float>(originX);
m_rowRenderOriginY = static_cast<float>(originY);
// 调试:把文本收集器临时指向一个**位图坐标**的本地容器(偏移 0),
// 渲染完成后连同矩形一起存进缓存,命中缓存时再按原点偏移回放。
// ⚠️ **无条件收集**(不管外层是否有收集器):行位图可能在任何一帧被渲染
//(例如用户在关闭调试时滚动过列表),若只在"外层有收集器"时才收集,
// 之后打开调试会命中缓存而永远拿不到文本框。收集成本只发生在**缓存未命中**的行上。
UiTextRectSink* const outerSink = UiGetTextRectSink();
RowTextCollector rowTextBoxes;
UiSetTextRectSink(&rowTextBoxes);
UiSetTextRectOffset(0.0f, 0.0f);
m_rowClipRender = true;
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
{
PaintGroupRow(rowGraphics, row);
}
else
{
PaintSignalRow(rowGraphics, row);
}
m_rowClipRender = false;
m_rowRenderActive = false;
cache.textRects = rowTextBoxes.rects;
UiSetTextRectOffset(0.0f, 0.0f);
UiSetTextRectSink(outerSink);
}
cache.key = key;
}
// 调试:把本行的文本框回放给外层收集器(缓存命中 / 刚渲染两条路共用这一段)。
if (UiTextRectSink* const outerSink = UiGetTextRectSink())
{
for (const gd::RectF& textRect : cache.textRects)
{
outerSink->OnTextRect(gd::RectF(textRect.X + static_cast<float>(originX),
textRect.Y + static_cast<float>(originY),
textRect.Width, textRect.Height));
}
}
// ⚠️ 整块 1:1 搬运(不经过 GDI+ 采样/混合),字的亚像素边缘才能原样保留。
// ⚠️ **必须自己给 DC 设裁剪**:`Graphics::GetHDC()` 交给我们的 DC 上,(a) GDI 绘制
// 不受 GDI+ `SetClip` 的约束(这是本文件多处注释过的坑),(b) 折起/展开动画时行会
// 滑进组名栏,必须裁到 `m_rowClipRect`。因此这里显式 `IntersectClipRect` 后再 `BitBlt`。
const HDC targetDc = g.GetHDC();
if (targetDc != nullptr)
{
const int saved = SaveDC(targetDc);
if (saved != 0)
{
const gd::RectF clip = m_rowClipActive ? m_rowClipRect : m_listRect;
IntersectClipRect(targetDc, 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)));
}
BitBlt(targetDc, originX, originY, width, height, cache.dc, 0, 0, SRCCOPY);
if (saved != 0)
{
RestoreDC(targetDc, saved);
}
g.ReleaseHDC(targetDc);
}
}
void SignalPanel::PaintGroupRow(gd::Graphics& g, int row)
{
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
{
return;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalGroup* group = m_dataSet->GroupAt(item.groupIndex);
if (group == nullptr)
{
return;
}
const gd::RectF rect = RowRect(row);
const bool hovered = (m_hoverRow == row);
if (hovered)
{
FillRowRect(g, rect, m_theme->hover);
}
FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height),
WithAlpha(m_theme->borderStrong, 0x90));
const float baseX = rect.X + kPadding * m_scale;
const float centerY = rect.Y + rect.Height * 0.5f;
const bool collapsed = IsGroupCollapsed(item.groupIndex);
// 折叠三角。
{
const float cx = baseX + kDisclosureWidth * m_scale * 0.5f;
const std::uint32_t color =
(hovered && m_hoverPart == RowPart::DISCLOSURE) ? m_theme->accent : m_theme->text;
UiDrawTriangleIcon(g, gd::RectF(cx - 6.0f * m_scale, centerY - 6.0f * m_scale, 12.0f * m_scale,
12.0f * m_scale),
color, collapsed ? IconDirection::RIGHT : IconDirection::DOWN);
}
// 分组复选框(三态:全选 / 部分 / 未选)。
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
const bool anyVisible = m_tab->GroupHasVisible(item.groupIndex);
UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale);
const gd::RectF closeRect = GroupCloseRect(row);
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
const std::wstring groupLabel = GroupLabelText(item.groupIndex);
// 项数文本与 `RowNameRect`(组名编辑框)、`GroupStatsRect`(命中区)共用同一来源,
// 保证“画的”与“点的”不漂移。
const std::wstring stats = GroupStatsText(row);
const float groupWidth = GroupLabelWidth(row);
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats);
const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale;
const gd::RectF statsTextRect(statsRight - statsWidth, rect.Y, statsWidth, rect.Height);
const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
const float nameAreaLeft =
nameLeft + groupWidth + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale;
const float nameWidth =
std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameAreaLeft);
// 序号以强调色突出,便于在多组之间快速定位。
PaintRowText(g, groupLabel, m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height),
m_theme->accent, TextAlign::LEFT);
// 竖线分隔符:以绘制方式实现,与文字基线无关,排版更稳。
FillRowRect(g, gd::RectF(nameLeft + groupWidth + kGroupSeparatorGap * m_scale,
rect.Y + 7.0f * m_scale, kGroupSeparatorWidth * m_scale,
rect.Height - 14.0f * m_scale),
WithAlpha(m_theme->borderStrong, 0xB0));
if (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row)
{
UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->uiSmall, m_scale);
}
else
{
PaintRowText(g, UiEllipsize(group->name, m_fonts->uiBold, nameWidth), m_fonts->uiBold,
gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong,
TextAlign::LEFT);
}
PaintRowText(g, stats, m_fonts->uiSmall, statsTextRect, m_theme->textMuted,
TextAlign::RIGHT);
// 关闭按钮(从工作区移除该分组)。
const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE);
if (closeHot)
{
UiFillRoundRect(g, closeRect, 3.0f * m_scale, m_theme->pressed);
}
UiDrawCrossIcon(g, closeRect, closeHot ? m_theme->danger : m_theme->textMuted, 1.5f * m_scale);
}
void SignalPanel::PaintSignalRow(gd::Graphics& g, int row)
{
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
{
return;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalRef ref{item.groupIndex, item.signalIndex};
const SignalData* signal = m_dataSet->Resolve(ref);
const SignalStyle* style = StyleOf(ref);
if (signal == nullptr || style == nullptr)
{
return;
}
const gd::RectF rect = RowRect(row);
const bool selected = (SelectedRef() == ref);
const bool hovered = (m_hoverRow == row);
if (selected)
{
FillRowRect(g, rect, m_theme->selection);
FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), m_theme->accent);
}
else if (hovered)
{
FillRowRect(g, rect, m_theme->hover);
}
const float baseX = rect.X + kPadding * m_scale + kIndent * m_scale;
const float centerY = rect.Y + rect.Height * 0.5f;
// 复选框。
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
// 可见性属于当前标签页,与全局共享的显示外观分开读取。
const bool visible = m_tab->IsVisible(ref);
UiDrawCheckBox(g, checkRect, visible ? 1 : 0, 3.0f * m_scale);
// 颜色块:叠加不透明度,透明度过低时自动补描边(与样式区色块规则一致)。
const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale,
centerY - kSwatchSize * m_scale * 0.5f, kSwatchSize * m_scale,
kSwatchSize * m_scale);
UiDrawSwatch(g, swatchRect, style->color, visible ? style->opacity : 0.3f, 2.5f * m_scale,
m_scale);
// 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。
// ⚠️ 可见性判据必须与命中测试共用 `SignalBadgeVisible`(否则“可见但未分配网格”的信号
// 会在未绘制的空白处仍可点出「所属网格」菜单)。
const bool badgeVisible = SignalBadgeVisible(ref);
const gd::RectF badgeRect = SignalBadgeRect(row);
const gd::RectF removeRect = SignalRemoveRect(row);
float nameRightLimit = removeRect.X - 6.0f * m_scale;
if (badgeVisible)
{
nameRightLimit = badgeRect.X - 6.0f * m_scale;
}
// 名称:完全保留**源表列名**(含 "[n]" 序号与括号描述),双击可重命名。
const float nameLeft = swatchRect.X + swatchRect.Width + 6.0f * m_scale;
if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row)
{
UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->ui, m_scale);
}
else
{
const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft);
const std::uint32_t nameColor = visible ? m_theme->text : m_theme->textMuted;
PaintRowText(g, UiEllipsize(signal->name, m_fonts->ui, nameWidth), m_fonts->ui,
gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT);
}
if (badgeVisible)
{
const bool badgeHot = hovered && (m_hoverPart == RowPart::LANE_BADGE);
UiFillRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f,
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f,
badgeHot ? m_theme->accent : m_theme->border, 1.0f);
PaintRowText(g, L"G" + std::to_wstring(m_tab->LaneOfSignal(ref) + 1), m_fonts->monoSmall,
badgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
}
// 「移除该信号」✕:位于信号条目最右端,尺寸/样式/右边距与分组行的关闭 ✕ 完全一致。
// 最后绘制,确保它永远压在最上层(与分组行 ✕ 的绘制顺序保持一致)。
{
const bool removeHot = hovered && (m_hoverPart == RowPart::REMOVE_SIGNAL);
if (removeHot)
{
UiFillRoundRect(g, removeRect, 3.0f * m_scale, m_theme->pressed);
}
UiDrawCrossIcon(g, removeRect, removeHot ? m_theme->danger : m_theme->textMuted,
1.5f * m_scale);
}
}
void SignalPanel::PaintScrollBar(gd::Graphics& g)
{
if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height)
{
return;
}
UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak);
UiFillRoundRect(g, ScrollThumbRect(), kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong);
}
void SignalPanel::PaintStyleSection(gd::Graphics& g)
{
if (m_styleRect.Height <= 0.0f)
{
return;
}
UiFillRect(g, m_styleRect, m_theme->panelBg);
// ---- 顶部「目标」行(无论是否已选中信号都要绘制:未选中时显示占位文本)----
PaintTargetRow(g);
const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef());
if (style == nullptr || SelectedRef().IsValid() == false)
{
// 提示文本只占「目标」行以下的区域,避免与目标行重叠。
const float top = m_targetRowRect.Y + m_targetRowRect.Height;
UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall,
gd::RectF(m_styleRect.X, top, m_styleRect.Width,
std::max(0.0f, m_styleRect.Y + m_styleRect.Height - top)),
m_theme->textMuted, TextAlign::CENTER);
return;
}
const float labelWidth = kStyleLabelWidth * m_scale;
// 行标签统一以整行为基准垂直居中,确保与同行控件纵向对齐。
auto paintRowLabel = [&](int rowIndex, const wchar_t* text)
{
const gd::RectF& row = m_styleRowRects[static_cast<std::size_t>(rowIndex)];
UiText(g, text, m_fonts->uiSmall, gd::RectF(row.X, row.Y, labelWidth, row.Height),
m_theme->textMuted, TextAlign::LEFT);
};
// ---- 第一行:颜色(预设色块 + 自定义颜色入口)----
{
paintRowLabel(0, L"颜色");
int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount);
// 判定“当前是否在用自定义色”:必须同时满足
// 1) 显示颜色等于记忆的自定义色;
// 2) 该颜色不是任何一个可见的预设色。
// 否则一旦自定义色与某个预设色相同时,点击那个预设色块会把高亮错误地
// 挪到自定义色块上(用户报告的“点预设色直接跳到自定义色块”)。
bool presetMatches = false;
for (int index = 0; index < m_colorCount; ++index)
{
if (style->color == palette[index % paletteCount])
{
presetMatches = true;
break;
}
}
const bool customActive = (style->color == style->customColor) && !presetMatches;
for (int index = 0; index < m_colorCount; ++index)
{
const gd::RectF& rect = m_colorRects[static_cast<std::size_t>(index)];
const std::uint32_t color = palette[index % paletteCount];
const bool hovered = (m_hoverStyle == StyleHit::COLOR && m_hoverStyleIndex == index);
const bool active = !customActive && (style->color == color);
// 预设色块同样叠加不透明度;透明度过低时自动补描边以保证可辨识(与自定义色块一致)。
UiDrawSwatch(g, rect, color, style->opacity, kCorner * m_scale, m_scale);
if (active || hovered)
{
const gd::RectF ring(rect.X - 2.0f * m_scale, rect.Y - 2.0f * m_scale,
rect.Width + 4.0f * m_scale, rect.Height + 4.0f * m_scale);
UiDrawRoundRect(g, ring, 5.0f * m_scale,
active ? m_theme->textStrong : m_theme->borderStrong,
active ? 1.6f : 1.0f);
}
}
// 自定义颜色入口:与预设色块完全同尺寸、同外框规则的小方格,仅多一个加号图标。
// 方格始终显示记忆的自定义颜色,因此从任意预设色再次点击它都能立即回到上次的自定义色。
const bool chipHot = (m_hoverStyle == StyleHit::CUSTOM_CHIP);
const std::uint32_t chipColor =
UiDrawSwatch(g, m_customChipRect, style->customColor, style->opacity, kCorner * m_scale, m_scale);
const std::uint32_t iconColor = IsDarkColor(chipColor) ? 0xFFFDF6E3u : 0xFF073642u;
UiDrawPlusIcon(g, m_customChipRect, iconColor, 1.6f * m_scale);
// 当前正在使用自定义颜色、或鼠标悬停时,显示与预设色块一致的外框。
if (customActive || chipHot)
{
const gd::RectF& chip = m_customChipRect;
const gd::RectF ring(chip.X - 2.0f * m_scale, chip.Y - 2.0f * m_scale,
chip.Width + 4.0f * m_scale, chip.Height + 4.0f * m_scale);
UiDrawRoundRect(g, ring, 5.0f * m_scale,
customActive ? m_theme->textStrong : m_theme->borderStrong,
customActive ? 1.6f : 1.0f);
}
}
// ---- 第二行:样式(线段 / 散点 / 点线)----
// ⚠️ 单点信号固定使用「散点」:「线段」在单个数据点上完全画不出来、「点线」与
// 「放大自动点线」语义重叠,因此它们对单点信号**置灰且不可点**(用户 2026-10-16 要求)。
{
paintRowLabel(1, L"样式");
for (int index = 0; index < kModeOptionCount; ++index)
{
const gd::RectF& rect = m_modeRects[static_cast<std::size_t>(index)];
const bool enabled = ModeOptionEnabled(index);
const bool active = (style->mode == kModeOptions[index]);
const bool hovered =
enabled && (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index);
UiFillRoundRect(g, rect, kCorner * m_scale,
active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg));
UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f);
UiText(g, DrawModeName(kModeOptions[index]), m_fonts->uiSmall, rect,
!enabled ? m_theme->textMuted
: (active ? m_theme->textStrong : m_theme->text),
TextAlign::CENTER);
}
}
// ---- 第三行:调整(系数 / 镜像 / 绝对值,三个切换类按钮)----
// 结算顺序固定为「系数 → 绝对值 → 镜像」;「系数」「镜像」点击后展开二级面板。
{
paintRowLabel(2, L"调整");
const auto paintToggle = [&](int index, const wchar_t* label, bool checked, StyleHit target)
{
const gd::RectF& rect = m_adjustRects[static_cast<std::size_t>(index)];
const bool hot = (m_hoverStyle == target);
UiFillRoundRect(g, rect, kCorner * m_scale,
checked ? m_theme->accentSoft : (hot ? m_theme->hover : m_theme->cardBg));
UiDrawRoundRect(g, rect, kCorner * m_scale,
checked ? m_theme->accent : m_theme->border, 1.0f);
UiText(g, label, m_fonts->uiSmall, rect,
checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
};
// 系数/偏移:系数不等于默认值 1、或任一偏移不为 0 时显示为"已启用"。
paintToggle(kAdjustGain, L"系数/偏移",
!GainIsDefault(style->gain) || style->Offset(), StyleHit::GAIN_ADJUST);
// 镜像:水平或垂直任一被勾选即显示为激活态。
paintToggle(kAdjustMirror, L"镜像", style->Mirrored(), StyleHit::MIRROR_ADJUST);
paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE);
}
// ---- 第四行:线宽(数值可点击输入)----
{
paintRowLabel(3, L"线宽");
const double ratio =
(static_cast<double>(style->lineWidth) - kWidthMin) / (kWidthMax - kWidthMin);
const bool active = m_draggingWidth || (m_hoverStyle == StyleHit::WIDTH_SLIDER);
UiDrawSlider(g, m_widthTrackRect, ratio, m_theme->accent, active, m_scale);
if (m_editField == StyleEditField::WIDTH)
{
UiDrawEditField(g, m_widthValueRect, m_edit, m_fonts->monoSmall, m_scale);
}
else
{
const std::wstring numberText = FormatNumber(style->lineWidth, 1);
const std::wstring fullText = numberText + L" px";
// 空间不足时省略单位,保证数值本身完整可读。
const std::wstring shown =
(UiMeasureTextWidth(m_fonts->monoSmall, fullText) >
m_widthValueRect.Width - 6.0f * m_scale)
? numberText
: fullText;
UiText(g, shown, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::RIGHT);
}
}
// ---- 第五行:透明度(数值可点击输入)----
{
paintRowLabel(4, L"透明度");
const bool active = m_draggingOpacity || (m_hoverStyle == StyleHit::OPACITY_SLIDER);
UiDrawSlider(g, m_opacityTrackRect, static_cast<double>(style->opacity), m_theme->accent, active,
m_scale);
if (m_editField == StyleEditField::OPACITY)
{
UiDrawEditField(g, m_opacityValueRect, m_edit, m_fonts->monoSmall, m_scale);
}
else
{
const int percent = static_cast<int>(style->opacity * 100.0f + 0.5f);
UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect,
m_theme->textStrong, TextAlign::RIGHT);
}
}
}
void SignalPanel::PaintTargetRow(gd::Graphics& g)
{
if (m_fonts == nullptr || m_theme == nullptr || m_targetRowRect.Width <= 0.0f)
{
return;
}
UpdateTargetRowGeometry();
const float labelWidth = kStyleLabelWidth * m_scale;
// 行标签与前五行一致(同一标签列宽度、同一纵向居中基准)。
UiText(g, L"目标", m_fonts->uiSmall,
gd::RectF(m_targetRowRect.X, m_targetRowRect.Y, labelWidth, m_targetRowRect.Height),
m_theme->textMuted, TextAlign::LEFT);
const SignalRef& target = SelectedRef();
const SignalData* signal =
(m_dataSet != nullptr && target.IsValid()) ? m_dataSet->Resolve(target) : nullptr;
// 没有可编辑的目标信号:只显示占位提示(与样式区其余行的"请选择信号"提示一致)。
if (signal == nullptr)
{
const float contentLeft = m_targetRowRect.X + labelWidth + kStyleLabelGap * m_scale;
UiText(g, L"未选择信号", m_fonts->uiSmall,
gd::RectF(contentLeft, m_targetRowRect.Y,
std::max(0.0f, m_targetRowRect.X + m_targetRowRect.Width - contentLeft),
m_targetRowRect.Height),
m_theme->textMuted, TextAlign::LEFT);
return;
}
// 名称:悬停时给一层与信号列表行一致的背景高亮(用户要求"显示与交互效果相同")。
if (m_hoverStyle == StyleHit::TARGET_NAME && m_targetNameRect.Width > 0.0f)
{
UiFillRoundRect(g, m_targetNameRect, kCorner * m_scale, m_theme->hover);
}
if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow < 0)
{
UiDrawEditField(g, EditFieldRect(), m_edit, m_fonts->ui, m_scale);
}
else
{
// 颜色与信号列表行一致:已绘制到网格用常态色,未绘制用弱色。
const std::uint32_t nameColor = (m_targetGeomLane >= 0) ? m_theme->text : m_theme->textMuted;
// ⚠️ 这里**不能**用 `PaintRowText`(它把文本裁到 `m_listRect`,而「目标」行在样式区、
// 列表矩形之外 ⇒ 文本会被完全裁掉,用户报告“信号名与 GX 标签都没正确绘制”)。
UiText(g, UiEllipsize(signal->name, m_fonts->ui, m_targetNameRect.Width), m_fonts->ui,
m_targetNameRect, nameColor, TextAlign::LEFT);
}
// GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求),
// 并与其它控件一样**右对齐到内容列右边缘**。
if (m_targetBadgeRect.Width > 0.0f)
{
const bool badgeHot = (m_hoverStyle == StyleHit::TARGET_BADGE);
UiFillRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
UiDrawRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
badgeHot ? m_theme->accent : m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall,
m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
}
}
} // namespace jdi