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
+710 -402

No files matched your search

+35 -1
View File
@@ -1201,7 +1201,8 @@ namespace jdi
* @brief 设置某种面板的**内容滚动偏移**(客户区像素,0 = 顶部)—— 唯一实现。
*
* ⚠️ 下面七个 `SetLane*Scroll` 都只是本函数的一行转发(保留是为了不改调用点与原语义)。
* 新增面板时**只需**在 `LanePanelScrollOf` / `PanelStyleOf` / `PanelTitle` 三处登记,
* 新增面板时**只需**在 `pvdetail::PanelTraitsOf`(`PlotViewInternal.hpp`)加一行:
* 标题 / 排版类别 / 滚动字段 / 面板状态 / 功能开关都在那一行里,
* 不要再复制一份"取字段 → 夹取 → 比较 → 写回"的实现。
*
* @param [IN] laneIndex 网格下标。
@@ -2574,6 +2575,39 @@ namespace jdi
*/
const std::vector<FreeMarkerContentEntry>& FreeMarkerContents(int laneIndex) const;
// ---- 「自由标记」面板内容绘制的**分段入口**(第 41 轮)-----------------------
// `PaintPanelMarkerGroups` 只负责**顺序**(普通分组栏 → 条目 → 表头 → 吸顶分组栏 →
// 滚动条 → 透明度滑块),各段的细节在这里。提取是纯搬移,
// 判据 = `panels -mode compare`(像素哈希)+ `rowgeom -mode compare`(逐行几何)。
// ⚠️ 顺序本身是硬约束:表头与吸顶分组栏必须画在条目**之后**,否则向上滚动的条目
// 会盖住它们(这是原实现里唯一真正需要"读整段代码才看得见"的约束)。
/** @brief 把「自由标记」六列的实测文本宽度写回 `stored`(= `Lane::freeMarkerColumnWidths`)。 */
void MeasureMarkerColumnWidths(const Lane& tabLane,
const std::vector<FreeMarkerContentEntry>& contents,
std::array<float, 6>& stored);
/** @brief 「自由标记」列分隔竖线(数据行与表头共用同一套列边界)。 */
void PaintMarkerColumnSeparators(gd::Graphics& g, const EdgePanelLayout& layout, float y,
float height);
/** @brief 「自由标记」一个信号分组栏(三角 + 名称 + `N 标记点` + ✕)。 */
void PaintMarkerGroupBar(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane,
const EdgePanelLayout::MarkerGroup& group, std::size_t laneIndex);
/** @brief 「自由标记」一个固定点条目(六列 + 分隔线 + 行尾 ✕)。 */
void PaintMarkerRow(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane,
const EdgePanelLayout::MarkerRow& markerRow,
const FreeMarkerContentEntry& entry, std::size_t laneIndex);
/** @brief 「自由标记」一个展开分组的表头行(不透明底衬 + 六列标题)。 */
void PaintMarkerTableHeader(gd::Graphics& g, const EdgePanelLayout& layout,
const EdgePanelLayout::MarkerGroup& group);
/** @brief 「自由标记」底部「标记透明度」滑块行。 */
void PaintMarkerOpacityRow(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane,
std::size_t laneIndex);
/** 按网格下标的「自由标记」内容缓存(`mutable`:只在绘制时按需重建)。 */
mutable std::vector<FreeMarkerContentCache> m_freeMarkerContent;
+98 -108
View File
@@ -1096,99 +1096,21 @@ namespace jdi
// ⚠️ 这些 `switch` **不得写 `default:`** —— 让 `-Wswitch` 在新增
// `EdgePanelKind` 而漏登记时直接报警(编译期保护,0 运行时代价)。
/** @brief 取某种面板的 UI 状态(展开/折起/位置)——可写版本。 */
inline LanePanelState& LanePanelStateOf(Lane& lane, EdgePanelKind kind)
{
switch (kind)
{
case EdgePanelKind::COUNT:
return lane.edgePanel;
case EdgePanelKind::CENTER:
return lane.edgeCenterPanel;
case EdgePanelKind::MEAN:
return lane.meanFilterPanel;
case EdgePanelKind::PEAK:
return lane.weakPeakPanel;
case EdgePanelKind::MARK:
return lane.freeMarkerPanel;
case EdgePanelKind::RESAMPLE:
return lane.resamplePanel;
case EdgePanelKind::CLIP:
return lane.clipPanel;
}
// 枚举值越界(只可能来自非法强制转换):返回一个安全的占位对象。
return lane.edgePanel;
}
/** @brief 取某种面板的 UI 状态——只读版本。 */
inline const LanePanelState& LanePanelStateOf(const Lane& lane, EdgePanelKind kind)
{
return LanePanelStateOf(const_cast<Lane&>(lane), kind);
}
/** @brief 取某种面板的**内容滚动偏移**(客户区像素)——可写版本。 */
inline float& LanePanelScrollOf(Lane& lane, EdgePanelKind kind)
{
switch (kind)
{
case EdgePanelKind::COUNT:
return lane.edgeCountScroll;
case EdgePanelKind::CENTER:
return lane.edgeCenterScroll;
case EdgePanelKind::MEAN:
return lane.meanFilterScroll;
case EdgePanelKind::PEAK:
return lane.weakPeakScroll;
case EdgePanelKind::MARK:
return lane.freeMarkerScroll;
case EdgePanelKind::RESAMPLE:
return lane.resampleScroll;
case EdgePanelKind::CLIP:
return lane.clipScroll;
}
return lane.meanFilterScroll;
}
/** @brief 取某种面板的**内容滚动偏移**——只读版本。 */
inline const float& LanePanelScrollOf(const Lane& lane, EdgePanelKind kind)
{
return LanePanelScrollOf(const_cast<Lane&>(lane), kind);
}
/** @brief 该面板对应的功能是否已启用(右键菜单里的开关)。 */
inline bool LanePanelEnabled(const Lane& lane, EdgePanelKind kind)
{
switch (kind)
{
case EdgePanelKind::COUNT:
return lane.edgeCount;
case EdgePanelKind::CENTER:
return lane.edgeCenter;
case EdgePanelKind::MEAN:
return lane.meanFilter;
case EdgePanelKind::PEAK:
return lane.weakPeak;
case EdgePanelKind::MARK:
return lane.freeMarker;
case EdgePanelKind::RESAMPLE:
return lane.resample;
case EdgePanelKind::CLIP:
return lane.clip;
}
return false;
}
// ---- 面板的「排版类别」与「滚动几何」的唯一来源 --------------------------------
// ---- 面板的「排版类别」与「静态特征」的唯一来源 --------------------------------
//
// ⚠️⚠️ 为什么要有这一层:面板的**分类布尔量**过去在三个地方各写一份
// (`EdgePanelWidth` / `EdgePanelLayoutFor` / `PaintEdgeCursor`),
// 每份都是 6 个 `kind == XXX` + 3 条组合判断。新增一种面板时若只改其中两处,
// 就会出现"宽度按 A 类算、绘制按 B 类画"的错位 —— 这正是历史上
// 「元素超出浮动面板」「内容区多出一段空白」「滚动条画在表头上」这类缺陷的共同形态。
// 现在只在这里分派一次,三个消费者都读同一份结果。
// 现在只在这里登记一次,所有消费者都读同一份结果。
//
// ⚠️ 同样**不写 `default:`**:新增 `EdgePanelKind` 而未登记时,
// ⚠️ `PanelTraitsOf` 的 `switch` **不写 `default:`**:新增 `EdgePanelKind` 而未登记时,
// `-Wswitch`(属 `-Wall`)会直接编译报警。
//
// ⚠️ 标题里只有「边沿计数」「中心轴边沿计数」需要追加运行时后缀(`EdgeRangeSuffix()`
// 取决于双光标是否启用),因此表里存的是**标题前缀 + 一个标志**,
// 而不是 `std::wstring`(无法 constexpr)或函数指针(会把"数据"重新变成"代码")。
/**
* @brief 一种浮动面板的排版类别(互斥 + 若干可选部件)。
@@ -1225,40 +1147,108 @@ namespace jdi
bool footerButtons() const { return buildButton || twoButtons; }
};
/** @brief 取某种面板的排版类别(唯一分类来源)。 */
inline PanelStyle PanelStyleOf(EdgePanelKind kind)
/** @brief `PanelStyle` 的三个具名构造函数(聚合初始化顺序易错,这里给出语义化入口)。 */
inline constexpr PanelStyle ListPanelStyle(bool buildButton, bool twoButtons) noexcept
{
return PanelStyle{true, false, false, buildButton, twoButtons, false};
}
inline constexpr PanelStyle CountTablePanelStyle() noexcept
{
return PanelStyle{false, true, false, false, false, false};
}
inline constexpr PanelStyle MarkerPanelStyle() noexcept
{
return PanelStyle{false, false, true, false, false, true};
}
/**
* @brief 一种浮动面板的全部**静态特征**(排版类别 / 标题 / 三个字段偏移)。
*
* 新增一种面板时**只允许**改三处:`EdgePanelKind` 加一个枚举值、
* 本表加一行、`PanelTitle` 不需要动(标题就在这一行里)。
* 其余消费者(宽度 / 布局 / 绘制 / 滚动 / 命中 / 调试盒 / 缓存键)全部自动跟上。
*/
struct PanelKindTraits
{
PanelStyle style; /**< 排版类别与底部控件。 */
const wchar_t* title = L""; /**< 标题前缀(`rangeSuffix` 为真时再追加区间后缀)。 */
bool rangeSuffix = false; /**< 标题是否追加 `EdgeRangeSuffix()`(仅两种边沿计数)。 */
float Lane::*scroll = nullptr; /**< 内容滚动偏移。 */
LanePanelState Lane::*panel = nullptr; /**< 面板 UI 状态(展开/折起/位置)。 */
bool Lane::*enabled = nullptr; /**< 功能开关(右键菜单里的勾选项)。 */
};
/** @brief 取某种面板的全部静态特征(**唯一登记点**)。 */
inline constexpr PanelKindTraits PanelTraitsOf(EdgePanelKind kind)
{
PanelStyle style;
switch (kind)
{
case EdgePanelKind::COUNT:
style.countTable = true;
break;
return PanelKindTraits{CountTablePanelStyle(), L"边沿计数", true,
&Lane::edgeCountScroll, &Lane::edgePanel, &Lane::edgeCount};
case EdgePanelKind::CENTER:
style.listRows = true;
break;
return PanelKindTraits{ListPanelStyle(false, false), L"中心轴边沿计数", true,
&Lane::edgeCenterScroll, &Lane::edgeCenterPanel,
&Lane::edgeCenter};
case EdgePanelKind::MEAN:
style.listRows = true;
style.buildButton = true;
break;
return PanelKindTraits{ListPanelStyle(true, false), L"均值滤波", false,
&Lane::meanFilterScroll, &Lane::meanFilterPanel,
&Lane::meanFilter};
case EdgePanelKind::PEAK:
style.listRows = true;
style.buildButton = true;
break;
return PanelKindTraits{ListPanelStyle(true, false), kWeakPeakTitle, false,
&Lane::weakPeakScroll, &Lane::weakPeakPanel, &Lane::weakPeak};
case EdgePanelKind::MARK:
style.markerGroups = true;
style.markerFooter = true;
break;
return PanelKindTraits{MarkerPanelStyle(), L"自由标记", false,
&Lane::freeMarkerScroll, &Lane::freeMarkerPanel,
&Lane::freeMarker};
case EdgePanelKind::RESAMPLE:
style.listRows = true;
style.buildButton = true;
break;
return PanelKindTraits{ListPanelStyle(true, false), kResampleTitle, false,
&Lane::resampleScroll, &Lane::resamplePanel, &Lane::resample};
case EdgePanelKind::CLIP:
style.listRows = true;
style.twoButtons = true;
break;
return PanelKindTraits{ListPanelStyle(false, true), kClipTitle, false,
&Lane::clipScroll, &Lane::clipPanel, &Lane::clip};
}
return style;
// 枚举值越界(只可能来自非法强制转换):退到「均值滤波」这份,保证指针非空。
return PanelKindTraits{ListPanelStyle(true, false), L"", false, &Lane::meanFilterScroll,
&Lane::meanFilterPanel, &Lane::meanFilter};
}
/** @brief 取某种面板的排版类别(唯一分类来源)。 */
inline PanelStyle PanelStyleOf(EdgePanelKind kind)
{
return PanelTraitsOf(kind).style;
}
/** @brief 取某种面板的 UI 状态(展开/折起/位置)——可写版本。 */
inline LanePanelState& LanePanelStateOf(Lane& lane, EdgePanelKind kind)
{
return lane.*(PanelTraitsOf(kind).panel);
}
/** @brief 取某种面板的 UI 状态——只读版本。 */
inline const LanePanelState& LanePanelStateOf(const Lane& lane, EdgePanelKind kind)
{
return LanePanelStateOf(const_cast<Lane&>(lane), kind);
}
/** @brief 取某种面板的**内容滚动偏移**(客户区像素)——可写版本。 */
inline float& LanePanelScrollOf(Lane& lane, EdgePanelKind kind)
{
return lane.*(PanelTraitsOf(kind).scroll);
}
/** @brief 取某种面板的**内容滚动偏移**——只读版本。 */
inline const float& LanePanelScrollOf(const Lane& lane, EdgePanelKind kind)
{
return LanePanelScrollOf(const_cast<Lane&>(lane), kind);
}
/** @brief 该面板对应的功能是否已启用(右键菜单里的开关)。 */
inline bool LanePanelEnabled(const Lane& lane, EdgePanelKind kind)
{
return lane.*(PanelTraitsOf(kind).enabled);
}
/**
+314 -276
View File
@@ -592,16 +592,312 @@ 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) {
std::size_t laneIndex)
{
const std::size_t index = laneIndex;
const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale;
bool markerAny = false;
@@ -626,272 +922,37 @@ namespace jdi
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);
// 右侧:`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>(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);
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];
// ---- 行的选中 / 悬停高亮(用户 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));
}
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);
PaintMarkerRow(g, layout, tabLane, markerRow, contents[markerRow.itemIndex], index);
}
// ---- 表头(每个展开分组一行,与条目共用列边界)----
// ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。
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);
PaintMarkerTableHeader(g, layout, group);
}
// ---- 吸顶的分组栏(最后画:压住向上滚动的条目)----
@@ -899,7 +960,7 @@ namespace jdi
{
if (group.pinned)
{
paintGroupBar(group);
PaintMarkerGroupBar(g, layout, tabLane, group, index);
}
}
@@ -909,30 +970,7 @@ namespace jdi
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
+9 -17
View File
@@ -19,25 +19,17 @@ namespace jdi
{
std::wstring PlotView::PanelTitle(EdgePanelKind kind) const
{
// ⚠️ 穷举且不写 `default:`(`-Wswitch` 会在新增面板时报警)。
switch (kind)
// ⚠️ 标题前缀来自**唯一登记表**(`PanelTraitsOf`):新增面板时不需要改这个函数。
// 只有「边沿计数」「中心轴边沿计数」需要追加运行时后缀(`EdgeRangeSuffix()` 取决于
// 双光标是否启用),所以表里存的是"前缀 + 标志"而不是 `std::wstring`(无法 constexpr)
// 或函数指针(会把"数据"重新变成"代码",新增面板又要写一个函数)。
const PanelKindTraits traits = PanelTraitsOf(kind);
std::wstring title(traits.title);
if (traits.rangeSuffix)
{
case EdgePanelKind::COUNT:
return std::wstring(L"边沿计数") + EdgeRangeSuffix();
case EdgePanelKind::CENTER:
return std::wstring(L"中心轴边沿计数") + EdgeRangeSuffix();
case EdgePanelKind::MEAN:
return std::wstring(L"均值滤波");
case EdgePanelKind::PEAK:
return std::wstring(kWeakPeakTitle);
case EdgePanelKind::MARK:
return std::wstring(L"自由标记");
case EdgePanelKind::RESAMPLE:
return std::wstring(kResampleTitle);
case EdgePanelKind::CLIP:
return std::wstring(kClipTitle);
title += EdgeRangeSuffix();
}
return std::wstring();
return title;
}
PlotView::PanelColumnMetrics PlotView::MeasurePanelColumns(const Lane& lane,
+25
View File
@@ -407,5 +407,30 @@ old keys = 919, new keys = 965
逐行状态打包进 `PanelParamState`(解析结果 / 两种条目指针 / 网格下标),
目的是把参数表压在 5 个 —— 一个个透传会让签名比函数体更难读。
---
## 十三、第 41 轮(收尾):面板登记表 + 标记面板绘制分段
> 触发:用户要求「按上轮报告 §17.6 继续推进」,并在本轮结束时判断**是否已达到可以结束
> 本次代码审查的程度**;体积一项按用户指示**继续暂停**(§17.6 第 3 项)。
| 节点 | 内容 | 验收标准 |
| --- | --- | --- |
| **P39** | `PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf` / `LanePanelStateOf` / `LanePanelEnabled` 五处穷举 `switch` 合成一张 `constexpr` 登记表 `PanelTraitsOf(kind)` | 编译零 warning;`panels -mode compare` 7 面板像素哈希逐项不变 |
| **P40** | `PaintPanelMarkerGroups`(约 340 行)拆成 6 个具名函数(分组栏 / 列宽 / 分隔线 / 条目 / 表头 / 透明度行),主函数只留"顺序" | `panels -mode compare` + `rowgeom -mode compare`(默认 + MF4)0 差异 |
### 13.1 本轮新增硬约束
1. **新增一种实验性功能面板只改两处**:`EdgePanelKind` 加一个枚举值 +
`pvdetail::PanelTraitsOf` 加一行(标题 / 排版类别 / 滚动字段 / 面板状态 / 功能开关全在那一行)。
`PlotView::PanelTitle` **不再需要改** —— 它只读表里的"标题前缀 + `rangeSuffix` 标志"。
2. **`PanelTraitsOf` 的 `switch` 一律不写 `default:`**:漏登记 `EdgePanelKind` 会被 `-Wswitch`
当场拦下(这是"编译期保护"的唯一执行点,其余四个消费者都只是取值)。
3. **`PanelStyle` 用三个具名构造函数**(`ListPanelStyle` / `CountTablePanelStyle` /
`MarkerPanelStyle`)构造,**禁止**在表里写 6 个裸 bool(聚合顺序错一位就变成"表格面板带按钮")。
4. **标记面板的绘制顺序是硬约束**:普通分组栏 → 条目 → 表头 → 吸顶分组栏 → 滚动条 → 透明度行。
它现在**只出现在 `PaintPanelMarkerGroups` 一个地方**(过去藏在一个 340 行函数的循环中间)。
新增任何"吸顶/悬浮"元素时,必须加在"条目之后"。
+74
View File
@@ -712,5 +712,79 @@ std::vector<PanelRowPlan> PlanListPanelRows(const Lane&, EdgePanelKind) const;
| 行数 | `PlotViewPanelPaint.cpp` 935 → 939;`PlotViewPanels.cpp` 1729 → 1766(新增两个几何函数,净增 37) |
| 样式 | 顺手把机械搬移遗留的"整个函数体多缩进 2 个 tab"修正 |
---
## 第 41 轮(2026-11-04):面板登记表 + 标记面板绘制分段(§17.6 第 1 / 第 2 项,收尾)
> 本轮之后**代码审查进入收尾**:所有"已知问题"要么已修、要么有实测数字支撑地**明确不做**
>(体积、`EdgeScanFor` 哈希化、`EdgePanelLayoutFor` 表驱动化),详见报告 §18。
### P39 面板的五个穷举 `switch` 合成一张 `constexpr` 登记表 ✅
改前新增一种面板要改三处(`PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf`),
实际连 `LanePanelStateOf` / `LanePanelEnabled` 一共是**五处穷举 switch**
(§17.6 只点了三处,另外两处更早就有)。
改后 `PlotViewInternal.hpp` 里只有**一个**登记点:
```cpp
struct PanelKindTraits
{
PanelStyle style; // 排版类别 + 底部控件
const wchar_t* title = L""; // 标题前缀
bool rangeSuffix = false; // 是否追加 EdgeRangeSuffix()
float Lane::*scroll = nullptr; // 内容滚动偏移
LanePanelState Lane::*panel = nullptr; // 面板 UI 状态
bool Lane::*enabled = nullptr; // 功能开关
};
inline constexpr PanelKindTraits PanelTraitsOf(EdgePanelKind kind); // 唯一 switch
```
* 五个消费者全部退化成一行取值:`return lane.*(PanelTraitsOf(kind).scroll);`
* **`PanelTitle` 不再是 switch**:标题是数据(前缀字符串 + 一个 `rangeSuffix` 标志),
运行时后缀只对两种「边沿计数」追加 ⇒ §17.6 里"`PanelTitle` 依赖运行时字符串所以先解决
'标题是函数'的问题"就此解决(不需要函数指针,也不需要 `std::wstring` 进表)。
* ⚠️ `PanelStyle` 的三个具名构造函数(`ListPanelStyle` / `CountTablePanelStyle` /
`MarkerPanelStyle`)是刻意加的:表里若写 6 个裸 bool,聚合顺序错一位就变成
"表格面板带按钮"这种没人看得出来的错误。
* 体积代价:**+512 B**(7 份 traits 记录进 `.rdata` + `PanelTitle` 多一个分支)。
用 512 B 换"新增面板只改两处 + 编译期强制登记",判定值得。
### P40 `PaintPanelMarkerGroups` 拆成 6 个具名函数 ✅
| 函数 | 职责 |
| --- | --- |
| `MeasureMarkerColumnWidths` | 六列实测文本宽度 → 写回 `Lane::freeMarkerColumnWidths` |
| `PaintMarkerGroupBar` | 一个信号分组栏(三角 / 名称 / `N 标记点` / ✕) |
| `PaintMarkerColumnSeparators` | 列分隔竖线(条目行与表头共用) |
| `PaintMarkerRow` | 一个固定点条目(六列 + 分隔线 + 行尾 ✕) |
| `PaintMarkerTableHeader` | 一个展开分组的表头(不透明底衬 + 六列标题) |
| `PaintMarkerOpacityRow` | 底部「标记透明度」滑块行 |
| `PaintPanelMarkerGroups` | **只负责顺序**:普通分组栏 → 条目 → 表头 → 吸顶栏 → 滚动条 → 透明度行 |
* 原实现把这条顺序约束埋在一个 340 行函数的循环中间("为什么表头要在这里画"),
现在它是主函数里唯一的逻辑,注释也只剩一句"后两段必须压在上面"。
* 提取时顺带把机械搬移遗留的"函数体多缩进 2 个 tab"修正(整个函数重新缩进)。
* **零体积极限验证**:P39 之后产物已经是 782,336 B,P40 之后**完全相同** ⇒ 纯搬移。
### 过程中的一个判断(值得记)
`contents`(固定点内容缓存)原来在"画完普通分组栏之后"才取,现在提前到最前面。
两者唯一的区别是缓存重建的时机,而 `FreeMarkerContents` **不改任何绘制状态**
(只读采样 + 拼字符串 + 写 `m_freeMarkerContent`)⇒ 对像素无影响,双基线也证实了。
### 本轮验证
| 项 | 结果 |
| --- | --- |
| `make release` / `make debug` / `make -C Tools` | 零 error / 零 warning |
| 产物 | 781,824 → **782,336 B**(+512,全部来自 P39 的登记表;P40 为 0) |
| `UnitTests` | `checks=260 failed=0 cases=19` |
| `OffscreenChecks -all` | 7/7 PASS,`checks=133 failed=0` |
| `panels -mode compare` | 7 个面板像素哈希**逐项不变**(含 `resample=2BFC74B3D9B3239C`) |
| `rowgeom -mode compare` | 965 + 7 键 0 差异;`-data MF4-Record.MF4` 1268 + 7 键 0 差异 |
| 行数 | `PlotViewPanelPaint.cpp` 939 → 977(6 个函数头 + 说明);`PlotViewPanels.cpp` 1766 → 1758(`PanelTitle` 变短) |
| 读点 | `PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf` / `LanePanelStateOf` / `LanePanelEnabled` 的调用点**一处未改**(签名不变) |
+93
View File
@@ -825,5 +825,98 @@ old keys = 919, new keys = 965
与 §17.3 同理:先给"吸顶推挤"单独一个可测函数。
3. 体积:等有新的冗余再说(用户已明确)。
---
## 十八、第 41 轮(2026-11-04,收尾):面板登记表 + 标记面板分段,以及"能否结束审查"的判定
### 18.1 结论
| §17.6 建议 | 结果 |
| --- | --- |
| 1. 三处穷举 `switch` 合成 `constexpr` 表 | ✅ 完成,而且实际收敛的是**五处**(`PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf` / `LanePanelStateOf` / `LanePanelEnabled`) |
| 2. `PaintPanelMarkerGroups` 再拆 | ✅ 拆成 6 个具名函数,主函数只剩"绘制顺序" |
| 3. 体积 | ⏸ 按用户指示不做(+512 B 是本轮唯一代价,来自登记表) |
### 18.2 本轮做了什么
**(1)面板静态特征收敛为唯一登记点**
```cpp
inline constexpr PanelKindTraits PanelTraitsOf(EdgePanelKind kind); // 唯一 switch
```
`PanelKindTraits{style, title, rangeSuffix, scroll, panel, enabled}` ——
新增一种面板只改**两处**:`EdgePanelKind` 加枚举值 + 表里加一行。
五个消费者的签名一字未改,调用点**一处未动**。
`PanelTitle` 从"返回 `std::wstring` 的 switch"变成"读表 + 可选追加 `EdgeRangeSuffix()`"。
这解决了 §17.6 留下的那个具体障碍:"标题是函数、依赖运行时字符串,所以没法进表"——
答案是**把标题降级为数据(前缀 + 标志)**,而不是把函数塞进表里。
**(2)标记面板绘制分段**
| 函数 | 职责 |
| --- | --- |
| `MeasureMarkerColumnWidths` | 六列实测宽度 → `Lane::freeMarkerColumnWidths` |
| `PaintMarkerGroupBar` | 分组栏(三角 / 名称 / `N 标记点` / ✕) |
| `PaintMarkerColumnSeparators` | 列分隔竖线(条目与表头共用) |
| `PaintMarkerRow` | 固定点条目(六列 + 分隔线 + ✕) |
| `PaintMarkerTableHeader` | 展开分组的表头 |
| `PaintMarkerOpacityRow` | 「标记透明度」滑块行 |
| `PaintPanelMarkerGroups` | **只负责顺序**:分组栏 → 条目 → 表头 → 吸顶栏 → 滚动条 → 透明度行 |
判据不是"看起来更清楚",而是 `panels` 的 7 个面板像素哈希 + `rowgeom` 的
965/1268 条几何键**逐项不变**;产物字节数在 P40 前后**完全相同**(782,336 B),
说明这一段是真正的零成本搬移。
### 18.3 现在可以结束这次代码审查了吗?——**可以**
这不是"我觉得差不多了",而是逐条对账的结果:
| 审查项 | 状态 | 证据 |
| --- | --- | --- |
| 体积(890,880 → 782,336 B,−12.2%) | 完成 | `objdump -h` 节表;`-fno-rtti` + terminate handler 覆盖 + 关异步展开表;红线 1 MB,余量 ≈260 KB |
| Debug 构建"名不副实" | 已修 | `DEBUG_OPT -g` 真正生效(Debug 6.16 MB 属预期) |
| 面板分派"改一次就出错" | 已消除 | 唯一登记表 + `-Wswitch` 编译期保护 |
| 平行数组错位 / 字段重载 | 已消除 | `std::vector<PanelRow>`(结构上不可能错位) |
| 几何算式两处/双遍历 | 已收敛 | `PanelColumnMetrics` + `PanelRowPlan` |
| 巨型函数(1900 / 340 行) | 已拆 | `PlotViewPanelPaint.cpp` 三个内容函数 + 本轮六个标记面板函数 |
| 过时文档/注释 | 已清理 | `UI_Refactor_*` 已删;`agent_mem.md` §2/§3 已校正 |
| 死代码 / 死字段 / TODO | 无 | `-Wall` 零警告 + 全成员引用计数 + 全仓 grep |
| 性能热点 | 无新发现 | 第 32 轮四处卡死已根治;三处"还能再快一点"均已量化并判定不做 |
**明确"不做"的三项(都有数字支撑,写在这里防止后续重复尝试)**:
1. `EdgeScanFor` 状态查找哈希化:198 状态 0.097 → 0.073 µs/次,但体积 **+5,632 B**。
2. `EdgePanelLayoutFor` 表驱动化:实测 1.9~2.5 µs/次(每帧约 30 µs),**不是热点**。
3. gdtoa(≈33 KB):MinGW 下只要还用 `std::to_*string`(含浮点)就必然链入,
根因在 libstdc++ 的 `os_defines.h` 无条件 `#define __USE_MINGW_ANSI_STDIO 1`。
**剩下的都属"收益 < 风险/成本"**:`PaintPanelListRows` 还可再拆(纯可读性)、
体积只在出现新冗余时再压、面板几何算式已经是单一来源。
### 18.4 合并前的建议动作(给用户)
1. **远端机器务必全量重编**:本分支动过 `Makefile`(`NO_RTTI` / `RELEASE_ONLY_OPT` /
Debug `-O0 -g`),而只改 Makefile **不会**触发重编 ⇒ 先 `make clean`,再 `make release`。
否则会误以为"体积优化无效"或"Debug 还是 -Oz"。
2. 合并前跑一遍**最小集**(见 `Tools/README.md` §5):
`make release` → `make -C Tools` → `UnitTests` → `OffscreenChecks -all`
→ `-run panels -mode compare`(注意:面板/几何基线是**本机生成**的,跨机器不可比,
远端首次应 `-mode record` 重建或直接跳过 compare)。
3. 分支顺序:`Code_Review` → `dev` → `main`。本分支只含代码 / 文档 / `Tools/`,
**没有**构建产物、没有 `Temp/`、没有测试数据。
### 18.5 本轮验证
| 项 | 结果 |
| --- | --- |
| `make release` / `make debug` / `make -C Tools` | 零 error / 零 warning |
| Release 产物 | **782,336 B**(+512,全部来自 P39 登记表;P40 为 0) |
| `UnitTests` | `checks=260 failed=0 cases=19` |
| `OffscreenChecks -all` | 7 检查 / **133** 断言,全 PASS |
| `panels -mode compare` | 7 个面板像素哈希**逐项不变** |
| `rowgeom -mode compare` | 965 + 7 键 / 1268 + 7 键(MF4),**0 差异** |
+62
View File
@@ -10814,3 +10814,65 @@ std::vector<PanelRowPlan> PlanListPanelRows(const Lane&, EdgePanelKind) const;
2. `PaintPanelMarkerGroups`(约 260 行)按"分组栏 / 表头 / 条目吸顶"再拆。
3. **体积按用户指示暂停投入**(等后续功能更新产生新冗余时再做)。
---
## 2026-11-04 第 41 轮(`Code_Review` 分支,收尾):面板登记表 + 标记面板绘制分段
> 本轮之后**代码审查进入收尾**(判定见 `Code_Review_Report.md` §18.3)。
> 代码:`Components/PlotView/{Inc,Src}`。判据 = `panels -mode compare` + `rowgeom -mode compare`。
### 1. ✅ **取代**第 35 / 40 轮的"新增面板只改三处"
**已作废**:旧规则「新增一种面板要在 `LanePanelScrollOf` / `PanelStyleOf` / `PanelTitle` 三处登记」。
**现行**:新增面板只改**两处** —— `EdgePanelKind` 加枚举值 + `pvdetail::PanelTraitsOf` 加一行。
```cpp
struct PanelKindTraits
{
PanelStyle style; // 排版类别 + 底部控件
const wchar_t* title = L""; // 标题**前缀**(数据,不是函数)
bool rangeSuffix = false; // 是否追加 EdgeRangeSuffix()
float Lane::*scroll = nullptr; // 内容滚动偏移
LanePanelState Lane::*panel = nullptr; // 面板 UI 状态
bool Lane::*enabled = nullptr; // 功能开关
};
inline constexpr PanelKindTraits PanelTraitsOf(EdgePanelKind kind); // **唯一** switch
```
五个消费者(`PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf` / `LanePanelStateOf` /
`LanePanelEnabled`)全部退化成一行取值,**签名未变、调用点一处未动**。
`-Wswitch` 保护仍只在 `PanelTraitsOf` 这一处(不写 `default:`)。
⚠️ `PanelStyle` 必须用三个具名构造函数(`ListPanelStyle` / `CountTablePanelStyle` /
`MarkerPanelStyle`):表里写 6 个裸 bool,聚合顺序错一位就是"表格面板带按钮"这种隐形错误。
**"标题是函数"的解法**:把标题降级成数据(前缀 + `rangeSuffix` 标志),
`PanelTitle` 只做 `前缀 + (rangeSuffix ? EdgeRangeSuffix() : L"")`。
不要为此引入函数指针(等于把数据重新变成代码,新增面板又要写函数)。
### 2. ✅ `PaintPanelMarkerGroups` 拆成 6 个具名函数
`MeasureMarkerColumnWidths` / `PaintMarkerGroupBar` / `PaintMarkerColumnSeparators` /
`PaintMarkerRow` / `PaintMarkerTableHeader` / `PaintMarkerOpacityRow`;
`PaintPanelMarkerGroups` 现在**只负责绘制顺序**:
**普通分组栏 → 条目 → 表头 → 吸顶分组栏 → 滚动条 → 透明度行**
🔴 硬规则:**表头与吸顶分组栏必须画在条目之后**(否则向上滚动的条目会盖住它们)。
这条约束现在只出现在一个函数里,是唯一"看代码就能看全"的顺序约束;新增悬浮/吸顶元素
一律加在"条目之后"。
### 3. 本轮数字
* Release 781,824 → **782,336 B(+512)**,全部来自登记表(7 份 traits 记录进 `.rdata`);
**P40 拆分前后字节数完全相同** ⇒ 纯搬移。
* release / debug / tools 全零 warning;`UnitTests` 260/19;`OffscreenChecks -all` 7 检查 / 133 断言。
* `panels -mode compare` 7 面板像素哈希逐项不变;`rowgeom compare` 965+7 / 1268+7 键 0 差异。
* 行数:`PlotViewPanelPaint.cpp` 939 → 977;`PlotViewPanels.cpp` 1766 → 1758。
### 4. 审查收尾时确认的三条"别再试"
1. `EdgeScanFor` 状态查找哈希化:**+5,632 B** / 每轮省 ≈4.8 µs ⇒ 不做。
2. `EdgePanelLayoutFor` 表驱动化:1.9~2.5 µs/次(每帧 ≈30 µs)⇒ 不是热点。
3. gdtoa(≈33 KB):libstdc++ 的 `os_defines.h` 无条件
`#define __USE_MINGW_ANSI_STDIO 1` ⇒ 只要还用 `std::to_*string`(含浮点)就去不掉。