refactor(panels): 面板静态特征收敛为唯一登记表 + 标记面板绘制分段(§17.6 第 1/2 项)

- 新增 pvdetail::PanelKindTraits + PanelTraitsOf(唯一 switch):标题 / 排版类别 /
  滚动字段 / 面板状态 / 功能开关同表登记;新增面板只改「枚举 + 一行」,
  -Wswitch 仍在唯一登记点生效。PanelTitle 改为「前缀 + rangeSuffix 标志」,
  不再依赖 switch(标题降级为数据,不再需要函数指针)。
- PaintPanelMarkerGroups(约 340 行)拆成 6 个具名函数,主函数只保留绘制顺序
  (普通分组栏 -> 条目 -> 表头 -> 吸顶栏 -> 滚动条 -> 透明度行);纯搬移、0 字节变化。
- 顺手修正该函数体遗留的多余缩进。

验证:release/debug/tools 零 warning;UnitTests 260/19;OffscreenChecks -all 7/133;
panels -mode compare 7 面板像素哈希逐项不变;rowgeom compare 965+7 / 1268+7 键 0 差异。
产物 781,824 -> 782,336 B(+512,全部来自登记表)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-04 17:22:06 +08:00
1 parent a9f1563896
commit 0c26da07d2
8 files changed
+761 -453

No files matched your search

+365 -327
View File
@@ -592,347 +592,385 @@ namespace jdi
}
/**
* @brief Marker panel content: per-signal group bars (collapsible) + table header +
* fixed-point rows, with sticky group bars.
// ---------------------------------------------------------------------------
// 「自由标记」面板内容(第 41 轮拆分)
//
// 原实现是一个约 340 行的单函数,内含 5 段互不相干的绘制(分组栏 / 列宽测量 /
// 条目行 / 表头 / 透明度滑块)。任何一段出问题都要在 300 行里找,而"表头与吸顶分组栏
// 必须最后画"这条**唯一真实的顺序约束**被埋在一个循环中间。现在拆成 6 个具名函数,
// `PaintPanelMarkerGroups` 只负责顺序,判据 = `panels -mode compare` +
// `rowgeom -mode compare`(两者都必须零差异)。
// ---------------------------------------------------------------------------
/**
* @brief 把「自由标记」六列的实测文本宽度写回 `Lane::freeMarkerColumnWidths`。
*
* Paint order is rows -> header -> sticky group bar (the last one covers rows that
* scroll up). Rows hidden under a sticky bar are already excluded by the layout.
* 含滚出视界的条目(列宽必须稳定,否则滚动时列会跳动);下一步的布局
* (`EdgePanelWidth` / `EdgePanelLayoutFor`)直接读这份宽度。
*/
void PlotView::MeasureMarkerColumnWidths(const Lane& tabLane,
const std::vector<FreeMarkerContentEntry>& contents,
std::array<float, 6>& stored)
{
float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
const auto measure = [this, &widest](std::size_t column, const std::wstring& text) {
if (text.empty() || column >= 6)
{
return;
}
widest[column] = std::max(widest[column], UiMeasureTextWidth(m_fonts->monoSmall, text));
};
// 「序号」列:取本网格所有分组里最大的序号文本宽度。
std::size_t maxSerial = 0;
std::vector<std::size_t> serialOrder;
for (const SignalRef& ref : tabLane.signals)
{
SortedFreeMarkerIndices(tabLane, ref, serialOrder);
maxSerial = std::max(maxSerial, serialOrder.size());
}
if (maxSerial > 0)
{
measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial));
}
for (const FreeMarkerContentEntry& entry : contents)
{
measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText);
measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText);
measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText);
measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText);
measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText);
}
for (std::size_t column = 0; column < 6; ++column)
{
// +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本
// 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。
const float want = (widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f;
if (std::fabs(stored[column] - want) > 0.5f)
{
stored[column] = want;
}
}
}
/**
* @brief 「自由标记」列分隔竖线(数据行与表头共用同一套列边界)。
*
* 在每列右边界(加上半个列间隙)画一条淡竖线,让「序号 / 标记值 / 枚举文本 / 增量 /
* 时间点 / 时间间隔」有明显的列分界(用户 2026-10-23 要求)。
* 最后一列不画(它右边紧接行尾 ✕)。
*/
void PlotView::PaintMarkerColumnSeparators(gd::Graphics& g, const EdgePanelLayout& layout,
float y, float height)
{
int lastVisible = -1;
for (int column = 0; column < 6; ++column)
{
if (layout.markerColumns[column].width > 0.0f)
{
lastVisible = column;
}
}
for (int column = 0; column < lastVisible; ++column)
{
const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
const float lineX = cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f;
UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale,
WithAlpha(m_theme->borderStrong, 0x6C), 1.0f);
}
}
/**
* @brief 「自由标记」一个信号分组栏(三角 + 名称 + `N 标记点` + ✕)。
*
* 底色 = 信号色;文字一律用 `UiContrastInk` 算出的自适应对比色。吸顶时这一栏要压住
* 下面滚动的条目,因此底色先铺**完全不透明**的同色、再叠 0xE8(观感不变)。
*/
void PlotView::PaintMarkerGroupBar(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane,
const EdgePanelLayout::MarkerGroup& group,
std::size_t laneIndex)
{
const ResolvedSignal resolved = ResolveSignal(group.ref);
if (resolved.data == nullptr)
{
return;
}
const std::uint32_t signalColor =
(resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border;
const UiInk ink = UiContrastInk(signalColor);
UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor);
UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。
const bool triangleHot = (m_freeMarkerTriangleHoverLane == static_cast<int>(laneIndex) &&
m_freeMarkerTriangleHoverSignal == group.ref);
UiDrawTriangleIcon(g, group.triangle, triangleHot ? m_theme->accentSoft : ink.text,
group.collapsed ? IconDirection::RIGHT : IconDirection::DOWN);
// 右侧:`N 标记点` + ✕(清除该信号全部固定点)。
std::size_t markerCount = 0;
for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems)
{
if (entry.ref == group.ref)
{
++markerCount;
}
}
const std::wstring countText = FormatInteger(markerCount) + L" 标记点";
const float countWidth = UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale;
const float countRight = group.close.X - 5.0f * m_scale;
UiTextClipped(g, countText, m_fonts->monoSmall,
gd::RectF(countRight - countWidth, group.header.Y, countWidth,
group.header.Height),
ink.text, TextAlign::RIGHT, layout.content);
const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale;
const float nameWidth = std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft);
UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth),
m_fonts->uiSmall,
gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height),
ink.text, TextAlign::LEFT, layout.content);
const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast<int>(laneIndex) &&
m_freeMarkerPanelHoverSignal == group.ref &&
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-1));
if (closeHot)
{
UiFillRoundRect(g, group.close, 3.0f * m_scale, WithAlpha(m_theme->danger, 0x77));
}
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text;
UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad,
group.close.X + group.close.Width - crossPad,
group.close.Y + group.close.Height - crossPad, crossColor, 1.5f * m_scale);
UiDrawLine(g, group.close.X + group.close.Width - crossPad, group.close.Y + crossPad,
group.close.X + crossPad, group.close.Y + group.close.Height - crossPad,
crossColor, 1.5f * m_scale);
}
/**
* @brief 「自由标记」一个固定点条目(六列 + 分隔线 + 行尾 ✕)。
*
* ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求):列宽已按实测文本宽度撑开。
* ⚠️ 文字必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`,**不认 GDI+ 裁剪**
* ⇒ 滚动到内容区上方的条目会把文字画到标题栏上(用户 2026-10-22 报告的显示错误)。
*/
void PlotView::PaintMarkerRow(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane,
const EdgePanelLayout::MarkerRow& markerRow,
const FreeMarkerContentEntry& entry, std::size_t laneIndex)
{
// ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)----
const bool selected = (tabLane.freeMarkerSelected == static_cast<int>(markerRow.itemIndex));
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(laneIndex) &&
m_freeMarkerRowHoverItem == markerRow.itemIndex);
if (selected || rowHot)
{
UiFillRect(g,
gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale,
markerRow.rect.Width, markerRow.rect.Height - 2.0f * m_scale),
selected ? WithAlpha(m_theme->accent, 0x3C) : WithAlpha(m_theme->accent, 0x1E));
}
const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) {
return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y,
layout.markerColumns[column].width, markerRow.rect.Height);
};
const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column,
const std::wstring& text, std::uint32_t color) {
if (layout.markerColumns[column].width <= 0.0f || text.empty())
{
return;
}
UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, TextAlign::LEFT,
layout.content);
};
paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial),
m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, m_theme->textStrong);
if (layout.markerHasEnumColumn)
{
paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text);
}
paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText, m_theme->textMuted);
// 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。
PaintMarkerColumnSeparators(g, layout, markerRow.rect.Y, markerRow.rect.Height);
// 行尾 ✕:移除该固定点(与之前一致)。
const gd::RectF& closeRect = markerRow.close;
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(laneIndex) &&
m_freeMarkerPanelHoverItem == markerRow.itemIndex);
UiFillRoundRect(g, closeRect, 3.0f * m_scale,
hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg);
UiDrawRoundRect(g, closeRect, 3.0f * m_scale, hot ? m_theme->danger : m_theme->border, 1.0f);
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted;
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
closeRect.X + closeRect.Width - crossPad,
closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale);
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad,
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor,
1.2f * m_scale);
}
/**
* @brief 「自由标记」一个展开分组的表头行(不透明底衬 + 六列标题)。
*
* 不透明底衬是必须的:表头画在条目**之后**,用来盖住向上滚动的条目。
*/
void PlotView::PaintMarkerTableHeader(gd::Graphics& g, const EdgePanelLayout& layout,
const EdgePanelLayout::MarkerGroup& group)
{
if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f)
{
return;
}
// 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。
UiFillRect(g, group.tableHeader, MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22));
UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f,
group.tableHeader.X + group.tableHeader.Width,
group.tableHeader.Y + group.tableHeader.Height - 1.0f,
WithAlpha(m_theme->borderStrong, 0x99), 1.0f);
for (int column = 0; column < 6; ++column)
{
const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall,
gd::RectF(cell.left, group.tableHeader.Y, cell.width,
group.tableHeader.Height),
m_theme->textMuted, TextAlign::LEFT, layout.content);
}
// 表头的列分隔竖线(与数据行同一套列边界)。
PaintMarkerColumnSeparators(g, layout, group.tableHeader.Y, group.tableHeader.Height);
}
/**
* @brief 「自由标记」底部「标记透明度」滑块行。
*
* 这一行**总是显示**(与「自由标记点选取」是否开启无关),几何全部取自布局
* (与命中测试同源);外观与信号样式区的「透明度」滑块完全相同(同一 `UiDrawSlider`)。
*/
void PlotView::PaintMarkerOpacityRow(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane, std::size_t laneIndex)
{
if (layout.opacityTrack.Width <= 0.0f)
{
return;
}
const gd::RectF& panelRect = layout.panel;
const float labelGap = kEdgePanelPad * m_scale;
UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall,
gd::RectF(panelRect.X + labelGap, layout.opacityValue.Y,
layout.opacityTrack.X - panelRect.X - labelGap,
layout.opacityValue.Height),
m_theme->textMuted, TextAlign::LEFT);
const bool sliderActive =
(m_dragMode == PlotDragMode::MARKER_OPACITY &&
m_markerOpacityDragLane == static_cast<int>(laneIndex)) ||
UiPointInRect(layout.opacityTrack, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
// 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。
UiDrawSlider(g, layout.opacityTrack, static_cast<double>(tabLane.freeMarkerOpacity),
m_theme->accent, sliderActive, m_scale);
const int percent = static_cast<int>(tabLane.freeMarkerOpacity * 100.0f + 0.5f);
UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, layout.opacityValue,
m_theme->textStrong, TextAlign::RIGHT);
}
/**
* @brief 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。
*
* 绘制顺序(硬约束):
* 普通分组栏 → 条目 → 表头 → 吸顶分组栏 → 滚动条 → 透明度滑块
* 后两段必须压在条目之上,否则向上滚动的条目会盖住表头/吸顶栏。
*/
void PlotView::PaintPanelMarkerGroups(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane, std::vector<Lane>& tabLanes,
std::size_t laneIndex) {
const Lane& tabLane, std::vector<Lane>& tabLanes,
std::size_t laneIndex)
{
const std::size_t index = laneIndex;
const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale;
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems)
{
markerAny = markerAny || item.ref.IsValid();
}
const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale;
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems)
{
markerAny = markerAny || item.ref.IsValid();
}
const int contentState = g.Save();
g.SetClip(layout.content, gd::CombineModeIntersect);
const int contentState = g.Save();
g.SetClip(layout.content, gd::CombineModeIntersect);
if (!markerAny)
{
// ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点
//(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。
const wchar_t* emptyHint = tabLane.freeMarkerCursor
? L"在网格内单击以固定数据点"
: L"开启「自由标记点选取」后单击网格可固定数据点";
UiTextClipped(g, emptyHint, m_fonts->uiSmall,
gd::RectF(layout.content.X, layout.content.Y, layout.content.Width,
groupRowHeight),
m_theme->textMuted, TextAlign::LEFT, layout.content);
}
if (!markerAny)
{
// ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点
//(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。
const wchar_t* emptyHint = tabLane.freeMarkerCursor
? L"在网格内单击以固定数据点"
: L"开启「自由标记点选取」后单击网格可固定数据点";
UiTextClipped(g, emptyHint, m_fonts->uiSmall,
gd::RectF(layout.content.X, layout.content.Y, layout.content.Width,
groupRowHeight),
m_theme->textMuted, TextAlign::LEFT, layout.content);
}
// ---- 分组栏 ----
// ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的
// 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。
const auto paintGroupBar = [this, &g, &layout, &tabLane, index](
const EdgePanelLayout::MarkerGroup& group)
{
const ResolvedSignal resolved = ResolveSignal(group.ref);
if (resolved.data == nullptr)
{
return;
}
const std::uint32_t signalColor =
(resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border;
// 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则),
// 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。
const UiInk ink = UiContrastInk(signalColor);
// 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变):
// 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。
UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor);
UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`):
// 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。
const std::vector<FreeMarkerContentEntry>& contents =
FreeMarkerContents(static_cast<int>(index));
// 六列的实测宽度存回 `Lane`:下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)要用。
MeasureMarkerColumnWidths(tabLane, contents, tabLanes[index].freeMarkerColumnWidths);
// 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。
const bool triangleHot =
(m_freeMarkerTriangleHoverLane == static_cast<int>(index) &&
m_freeMarkerTriangleHoverSignal == group.ref);
UiDrawTriangleIcon(g, group.triangle,
triangleHot ? m_theme->accentSoft : ink.text,
group.collapsed ? IconDirection::RIGHT
: IconDirection::DOWN);
// ---- 普通分组栏(吸顶的那些留到最后画)----
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (!group.pinned)
{
PaintMarkerGroupBar(g, layout, tabLane, group, index);
}
}
// 右侧:`N 标记点` + ✕(清除该信号全部固定点)。
std::size_t markerCount = 0;
for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems)
{
if (entry.ref == group.ref)
{
++markerCount;
}
}
const std::wstring countText = FormatInteger(markerCount) + L" 标记点";
const float countWidth =
UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale;
const float countRight = group.close.X - 5.0f * m_scale;
UiTextClipped(g, countText, m_fonts->monoSmall,
gd::RectF(countRight - countWidth, group.header.Y, countWidth,
group.header.Height),
ink.text, TextAlign::RIGHT, layout.content);
// ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)----
for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows)
{
if (markerRow.itemIndex >= contents.size())
{
continue;
}
PaintMarkerRow(g, layout, tabLane, markerRow, contents[markerRow.itemIndex], index);
}
const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale;
const float nameWidth =
std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft);
UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth),
m_fonts->uiSmall,
gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height),
ink.text, TextAlign::LEFT, layout.content);
// ---- 表头(每个展开分组一行,与条目共用列边界)----
// ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
PaintMarkerTableHeader(g, layout, group);
}
const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverSignal == group.ref &&
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-1));
if (closeHot)
{
UiFillRoundRect(g, group.close, 3.0f * m_scale,
WithAlpha(m_theme->danger, 0x77));
}
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text;
UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad,
group.close.X + group.close.Width - crossPad,
group.close.Y + group.close.Height - crossPad, crossColor,
1.5f * m_scale);
UiDrawLine(g, group.close.X + group.close.Width - crossPad,
group.close.Y + crossPad, group.close.X + crossPad,
group.close.Y + group.close.Height - crossPad, crossColor,
1.5f * m_scale);
};
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (!group.pinned)
{
paintGroupBar(group);
}
}
// ---- 吸顶的分组栏(最后画:压住向上滚动的条目)----
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (group.pinned)
{
PaintMarkerGroupBar(g, layout, tabLane, group, index);
}
}
// ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)----
// ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`):
// 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。
const std::vector<FreeMarkerContentEntry>& contents =
FreeMarkerContents(static_cast<int>(index));
// 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。
// 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。
{
float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
const auto measure = [this, &widest](std::size_t column, const std::wstring& text) {
if (text.empty() || column >= 6)
{
return;
}
widest[column] = std::max(widest[column],
UiMeasureTextWidth(m_fonts->monoSmall, text));
};
// 「序号」列:取本网格所有分组里最大的序号文本宽度。
std::size_t maxSerial = 0;
std::vector<std::size_t> serialOrder;
for (const SignalRef& ref : tabLane.signals)
{
SortedFreeMarkerIndices(tabLane, ref, serialOrder);
maxSerial = std::max(maxSerial, serialOrder.size());
}
if (maxSerial > 0)
{
measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial));
}
for (const FreeMarkerContentEntry& entry : contents)
{
measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText);
measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText);
measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText);
measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText);
measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText);
}
std::array<float, 6>& stored =
tabLanes[index].freeMarkerColumnWidths;
for (std::size_t column = 0; column < 6; ++column)
{
// +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本
// 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。
const float want =
(widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f;
if (std::fabs(stored[column] - want) > 0.5f)
{
stored[column] = want;
}
}
}
// 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线,
// 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界
//(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。
const auto paintColumnSeparators = [this, &g, &layout](float y, float height) {
int lastVisible = -1;
for (int column = 0; column < 6; ++column)
{
if (layout.markerColumns[column].width > 0.0f)
{
lastVisible = column;
}
}
for (int column = 0; column < lastVisible; ++column)
{
const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
const float lineX =
cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f;
UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale,
WithAlpha(m_theme->borderStrong, 0x6C), 1.0f);
}
};
for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows)
{
if (markerRow.itemIndex >= contents.size())
{
continue;
}
const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex];
g.Restore(contentState);
// ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)----
const bool selected =
(tabLane.freeMarkerSelected == static_cast<int>(markerRow.itemIndex));
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(index) &&
m_freeMarkerRowHoverItem == markerRow.itemIndex);
if (selected || rowHot)
{
UiFillRect(g,
gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale,
markerRow.rect.Width,
markerRow.rect.Height - 2.0f * m_scale),
selected ? WithAlpha(m_theme->accent, 0x3C)
: WithAlpha(m_theme->accent, 0x1E));
}
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
PaintPanelScrollBar(g, layout, *m_theme);
const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) {
return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y,
layout.markerColumns[column].width,
markerRow.rect.Height);
};
// ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求):
// 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。
// ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`,
// **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上
//(用户 2026-10-22 报告的显示错误)。
const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column,
const std::wstring& text, std::uint32_t color) {
if (layout.markerColumns[column].width <= 0.0f || text.empty())
{
return;
}
UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color,
TextAlign::LEFT, layout.content);
};
paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial),
m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText,
m_theme->textStrong);
if (layout.markerHasEnumColumn)
{
paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text);
}
paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText,
m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText,
m_theme->textMuted);
// 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。
paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height);
// 行尾 ✕:移除该固定点(与之前一致)。
const gd::RectF& closeRect = markerRow.close;
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverItem == markerRow.itemIndex);
UiFillRoundRect(g, closeRect, 3.0f * m_scale,
hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg);
UiDrawRoundRect(g, closeRect, 3.0f * m_scale,
hot ? m_theme->danger : m_theme->border, 1.0f);
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted;
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
closeRect.X + closeRect.Width - crossPad,
closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale);
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad,
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad,
crossColor, 1.2f * m_scale);
}
// ---- 表头(每个展开分组一行,与条目共用列边界)----
// ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f)
{
continue;
}
// 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。
UiFillRect(g, group.tableHeader,
MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22));
UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f,
group.tableHeader.X + group.tableHeader.Width,
group.tableHeader.Y + group.tableHeader.Height - 1.0f,
WithAlpha(m_theme->borderStrong, 0x99), 1.0f);
for (int column = 0; column < 6; ++column)
{
const EdgePanelLayout::MarkerColumn& cell =
layout.markerColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall,
gd::RectF(cell.left, group.tableHeader.Y, cell.width,
group.tableHeader.Height),
m_theme->textMuted, TextAlign::LEFT, layout.content);
}
// 表头的列分隔竖线(与数据行同一套列边界)。
paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height);
}
// ---- 吸顶的分组栏(最后画:压住向上滚动的条目)----
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (group.pinned)
{
paintGroupBar(group);
}
}
g.Restore(contentState);
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
PaintPanelScrollBar(g, layout, *m_theme);
// ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)----
if (layout.opacityTrack.Width > 0.0f)
{
// 几何全部取自布局(与命中测试同源)。
const gd::RectF& panelRect = layout.panel;
const float labelGap = kEdgePanelPad * m_scale;
UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall,
gd::RectF(panelRect.X + labelGap, layout.opacityValue.Y,
layout.opacityTrack.X - panelRect.X - labelGap,
layout.opacityValue.Height),
m_theme->textMuted, TextAlign::LEFT);
const bool sliderActive =
(m_dragMode == PlotDragMode::MARKER_OPACITY &&
m_markerOpacityDragLane == static_cast<int>(index)) ||
UiPointInRect(layout.opacityTrack, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
// 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。
UiDrawSlider(g, layout.opacityTrack,
static_cast<double>(tabLane.freeMarkerOpacity), m_theme->accent,
sliderActive, m_scale);
const int percent =
static_cast<int>(tabLane.freeMarkerOpacity * 100.0f + 0.5f);
UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall,
layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT);
}
// ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)----
PaintMarkerOpacityRow(g, layout, tabLane, index);
}
} // namespace jdi