Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelPaint.cpp
T
DeepSeek-V4.1-Flash 24f0bf849d 修复悬停高亮变长方形色块/联动失效 + 信号列表动画卡顿真因(行位图缓存)
一、悬停高亮恢复"整条曲线发光"(上一轮裁成鼠标附近一段 ⇒ 满幅方波上就是个长方形色块)
光晕的语义是"整条曲线一起发光",对编码器这种满幅方波,一列包络本来就跨满网格高,
裁成 144px 宽 × 满高的窗口在屏幕上就是一个像选区框的色块(用户 2026-10-18 第二轮报告)。
修法(PlotViewPaint.cpp):
- PaintSignalHaloFor 恢复**整条**几何(包络带 FillPath / 折线 DrawLines);
- 新增 PaintSignalHaloCached:光晕几何只取决于「信号 + 视界 + 网格几何 + 样式」,**与鼠标无关**
  ⇒ 渲染一次进离屏位图,之后每帧只做一次 AlphaBlend(源位图预乘 alpha,用 256×256 查表);
- 缓存键与 AcquirePolyline 同源,**不含网格屏幕位置**(滚动网格只按新位置贴同一张位图)。
  210ms/帧(整条描边)→ 一次性 15ms 渲染 + 每帧 ~2ms 贴图。
halocheck 校验:悬停必命中(60/60 位置)、光晕贴合带宽(outsideBand=0)、不越出网格、
bbox 覆盖整条曲线(不是中段方块)、列表行悬停联动仍生效。

二、信号列表动画卡顿的三个真因(探针 panelstutter:129 个可见网格 + 反复"展开→切组复选框→收起")
1) 面板每帧重画 ~36 行 × ~0.35ms(十来个 GDI+ 原语)⇒ panel.Paint 24~35ms;
2) 动画帧里仍在预热网格图层:每帧重建 1 个网格图层,2500 万采样信号 10~30ms ⇒ 帧被拉长;
3) 勾选分组会把上百个**离屏**网格排进待重画队列,随后每帧补 1 个 ⇒ 数秒持续掉帧。
修法:
1) 行位图缓存(SignalPanel::PaintRowCached):按"行的可见内容"做键,整行渲染进离屏 DIB,
   命中时一次 BitBlt。panel.Paint 24~35ms → 5~6ms。
   ⚠️ 必须 DIB + 内存 DC(gd::Bitmap 上 GDI 会关闭 ClearType,整列文字退化);
   ⚠️ 行内几何在 RowRect 里平移(GDI 文本不认 GDI+ 世界变换,否则文字画到小位图之外);
   ⚠️ 渲染到行位图时不按列表矩形裁剪,裁剪放到贴图时的 DC(GDI 不认 GDI+ 裁剪,
      必须自己 SaveDC + IntersectClipRect,折起动画时行不许画到组名上);
   ⚠️ 缓存上限 192 行(≈10MB)。静态画面逐像素一致(面板区域 54/275200 不同)。
2) 新增 PlotView::SetAnimationBusy:UI 动画期间不预热网格图层(队列保留,动画结束后继续)。
3) 待重画队列只保留可视 + 预取带内的网格;RollWideBuffer 不再登记离屏网格。
实测:展开 p50 35ms → 13ms、收起 33ms → 13ms、动画帧内图层重建 56 次 → 0 次。

三、试过并放弃(避免以后重走)
- ExtTextOutW 替代 DrawTextW:快 2~4 倍但光栅化不同(面板 10% 像素差异)⇒ 观感变化,放弃;
- 用 gd::Bitmap 做行位图:文字丢失 ClearType,放弃。

四、验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 .preflight/perf/panelstutter_main.cpp(8/8)、halocheck_main.cpp(16/16);
- 回归 verify 49/49、panelanim 158/158、hoverdrag 20/20、gridanim 93/93、lanescroll 23/23、
  datacheck 69/69、edgecheck 46/46、errcheck 19/19、panflash 19/19、stress 6/6;
- make release / make debug 零警告(TARGET_FILE_NAME=TargetCheck/TargetCheckD)。

文档:Documents/agent_mem.md 追加本轮小节(含 4 条新规则:高亮语义不得为性能让步、
GDI 文本的三个隐形陷阱、面板卡顿先量"每行成本×可见行数"与"本帧是否顺带做绘图区重活"、
动画帧内绝不做可延迟的整帧级工作)。
2026-10-18 21:40:56 +08:00

949 lines
37 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 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
//
// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
#include "SignalPanelInternal.hpp"
#include <cstring>
namespace jdi
{
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());
}
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());
}
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);
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.key = key;
}
// ⚠️ 整块 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);
// 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。
const int laneIndex = m_tab->LaneOfSignal(ref);
const bool badgeVisible = (laneIndex >= 0) && visible;
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(laneIndex + 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