refactor(panels): 行追加收敛为 AppendRow 唯一入口(消除平行数组错位隐患)

背景:Code Review 报告 §12.7 建议 1。"改一次面板布局就出错"的**机制性**原因,
是 `EdgePanelLayout` 的每一行对应 **18 个按行平行的 `std::vector`**,
而每个排版分支都要在同一位置、按同一顺序 push 恰好 18 项。只要某个分支少推一项,
那一列之后的所有行都会读到**别人的**矩形(`panels` 的长度不变式只能事后报警)。

本次做法(比"合并成一个 PanelRow 结构体 + 314 处引用"风险低得多、收益相同)
* `EdgePanelLayout::AppendRow(ref, header, nameRect, triangle, check)`:
  **唯一**允许新增行的入口,18 个数组一次补齐(参数行/重采样行一律为空矩形)。
* 三个 push 块改为:
  * 边沿计数表格:13 次 push → **1 次 `AppendRow`**;
  * 列表式:13 次 push → **1 次 `AppendRow`**;参数行/重采样行改为
    `rowXxx.back() = …` **赋值**(长度天然对齐,不再需要 push);
  * 「未勾选/收起」分支里那 6+4 次补空 push **整个删掉**(AppendRow 已补过)。
* ⇒ 新增一列只需改 `AppendRow` 一处(新增行的分支不必逐个补);
  新增行必须走 `AppendRow`(新增列不会漏)。

为什么没有做"完整合并成 std::vector<PanelRow> + 列视图"
* 列视图方案需要给 `EdgePanelLayout` 写自定义拷贝构造/赋值(视图持有 `panelRows` 的地址),
  而该类型是 `EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector`,
  拷贝路径是热路径 —— 一旦拷贝语义写错,会产生**比原缺陷更隐蔽**的悬垂视图。
* 本轮先消除"错位"这个真实隐患(AppendRow 已做到),
  完整结构合并留到有"逐行几何基线"检查之后再做(已记入后续建议)。

验证
* `panels -mode compare` → 7 个面板像素哈希 + 几何**逐项不变**。
* `panelscroll`(MF4-Record,566 行)→ 7 种面板的行数组长度全部等于 566,行为不变。
* `OffscreenChecks -all` → 6/6 PASS,`checks=108 failed=0`。
* `UnitTests` → `checks=260 failed=0`。
* release 全量零 error / 零 warning;产物 786,432 → **785,408 B**(−1,024)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-04 15:18:01 +08:00
1 parent 38fbd4b465
commit 85aaed55f6
2 files changed
+66 -62

No files matched your search

+40
View File
@@ -2070,6 +2070,7 @@ namespace jdi
/** @brief 「均值滤波」面板每个信号标题栏左侧的**展开 / 收起三角**(仅勾选后出现)。 */ /** @brief 「均值滤波」面板每个信号标题栏左侧的**展开 / 收起三角**(仅勾选后出现)。 */
std::vector<gd::RectF> rowTriangles; std::vector<gd::RectF> rowTriangles;
std::vector<SignalRef> rowRefs; std::vector<SignalRef> rowRefs;
/** /**
* @brief 「自由标记」面板的一行(一个固定点)。 * @brief 「自由标记」面板的一行(一个固定点)。
* *
@@ -2210,6 +2211,45 @@ namespace jdi
MARKER_COL_TIME = 4, MARKER_COL_TIME = 4,
MARKER_COL_DT = 5 MARKER_COL_DT = 5
}; };
/**
* @brief 追加一**行**(条目栏 / 名称 / 三角 / 复选框),其余列一律补空矩形。
*
* ⚠️⚠️ 这是**唯一**允许新增行的入口。面板每行对应 18 个平行数组
* (`rowRefs` / `rowHeaders` / `rowChecks` / `rowLevelTracks` / …);
* 如果每个排版分支各写一遍 `push_back`,只要某分支漏推一项,
* 那一列之后的所有行都会**读到别人的矩形**("改一次布局就出错"的典型机制)。
* 现在:新增行的分支只调本函数,**新增一列只需改这里一处**;
* 参数行 / 重采样行则用 `rowXxx.back() = …` 赋值(长度天然对齐)。
*
* @param [IN] ref 该行对应的信号。
* @param [IN] header 信号色叠底条目栏(整行宽度)。
* @param [IN] nameRect 名称(表格里的名称列)。
* @param [IN] triangle 左侧展开 / 收起三角(不适用时传空矩形)。
* @param [IN] check 右端复选框。
*/
void AppendRow(const SignalRef& ref, const gd::RectF& header, const gd::RectF& nameRect,
const gd::RectF& triangle, const gd::RectF& check)
{
rowRefs.push_back(ref);
rowHeaders.push_back(header);
rowNameRects.push_back(nameRect);
rowTriangles.push_back(triangle);
rowChecks.push_back(check);
rowPoints.push_back(gd::RectF());
rowIterations.push_back(gd::RectF());
rowStats.push_back(gd::RectF());
rowLevelTracks.push_back(gd::RectF());
rowLevelValues.push_back(gd::RectF());
rowLevelResets.push_back(gd::RectF());
rowIterTracks.push_back(gd::RectF());
rowIterValues.push_back(gd::RectF());
rowIterResets.push_back(gd::RectF());
rowInfoMaxInterval.push_back(gd::RectF());
rowInfoMinInterval.push_back(gd::RectF());
rowIntervalField.push_back(gd::RectF());
rowFrequencyField.push_back(gd::RectF());
}
}; };
/** /**
+26 -62
View File
@@ -735,22 +735,12 @@ namespace jdi
{ {
continue; continue;
} }
layout.rowRefs.push_back(ref); // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):18 个平行数组一次补齐,
layout.rowHeaders.push_back(gd::RectF(contentLeft, rowY, innerWidth, rowH)); // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。
layout.rowNameRects.push_back( layout.AppendRow(ref, gd::RectF(contentLeft, rowY, innerWidth, rowH),
gd::RectF(contentLeft + 4.0f * m_scale, rowY, gd::RectF(contentLeft + 4.0f * m_scale, rowY,
std::max(10.0f, nameColumn - 8.0f * m_scale), rowH)); std::max(10.0f, nameColumn - 8.0f * m_scale), rowH),
layout.rowTriangles.push_back(gd::RectF()); gd::RectF(), gd::RectF());
layout.rowChecks.push_back(gd::RectF());
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
rowY += rowH; rowY += rowH;
} }
} }
@@ -886,42 +876,26 @@ namespace jdi
checked = (item != nullptr); checked = (item != nullptr);
itemCollapsed = (item != nullptr && item->collapsed); itemCollapsed = (item != nullptr && item->collapsed);
} }
layout.rowRefs.push_back(ref);
// 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。
layout.rowHeaders.push_back( // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):18 个平行数组一次补齐,
gd::RectF(contentLeft, rowY, innerWidth, groupRow)); // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。
layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow)); // 下面的参数行 / 重采样行一律用 `rowXxx.back() = …` **赋值**(长度天然对齐)。
layout.rowTriangles.push_back( layout.AppendRow(
ref, gd::RectF(contentLeft, rowY, innerWidth, groupRow),
gd::RectF(contentLeft, rowY, innerWidth, groupRow),
(checked && !clipPanel) (checked && !clipPanel)
? gd::RectF(contentLeft + 3.0f * m_scale, ? gd::RectF(contentLeft + 3.0f * m_scale,
rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f,
kEdgePanelRowTriangleSize * m_scale, kEdgePanelRowTriangleSize * m_scale,
kEdgePanelRowTriangleSize * m_scale) kEdgePanelRowTriangleSize * m_scale)
: gd::RectF()); : gd::RectF(),
layout.rowChecks.push_back(
gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale, gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale,
rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize)); rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize));
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
rowY += groupRow; rowY += groupRow;
// 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。 // 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。
if (!checked || itemCollapsed || clipPanel) if (!checked || itemCollapsed || clipPanel)
{ {
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
if (resamplePanel)
{
layout.rowInfoMaxInterval.push_back(gd::RectF());
layout.rowInfoMinInterval.push_back(gd::RectF());
layout.rowIntervalField.push_back(gd::RectF());
layout.rowFrequencyField.push_back(gd::RectF());
}
continue; continue;
} }
if (resamplePanel) if (resamplePanel)
@@ -929,27 +903,17 @@ namespace jdi
// 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读) // 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读)
// ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。 // ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。
// 每行 = 左侧标签 + 右侧数值/输入框区域。 // 每行 = 左侧标签 + 右侧数值/输入框区域。
layout.rowLevelTracks.push_back(gd::RectF()); layout.rowInfoMaxInterval.back() = gd::RectF(contentLeft, rowY, innerWidth, sliderRow);
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
layout.rowInfoMaxInterval.push_back(
gd::RectF(contentLeft, rowY, innerWidth, sliderRow));
rowY += sliderRow; rowY += sliderRow;
layout.rowInfoMinInterval.push_back( layout.rowInfoMinInterval.back() = gd::RectF(contentLeft, rowY, innerWidth, sliderRow);
gd::RectF(contentLeft, rowY, innerWidth, sliderRow));
rowY += sliderRow; rowY += sliderRow;
// 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。 // 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。
const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale; const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale;
const float fieldWidth = const float fieldWidth =
std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale); std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale);
layout.rowIntervalField.push_back( layout.rowIntervalField.back() = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow);
gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow));
rowY += sliderRow; rowY += sliderRow;
layout.rowFrequencyField.push_back( layout.rowFrequencyField.back() = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow);
gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow));
rowY += sliderRow; rowY += sliderRow;
continue; continue;
} }
@@ -961,22 +925,22 @@ namespace jdi
} }
const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight); trackWidth, trackHeight);
layout.rowLevelTracks.push_back(pointsTrack); layout.rowLevelTracks.back() = pointsTrack;
layout.rowLevelValues.push_back( layout.rowLevelValues.back() =
gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn,
sliderRow)); sliderRow);
layout.rowLevelResets.push_back( layout.rowLevelResets.back() =
gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize);
rowY += sliderRow; rowY += sliderRow;
const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight); trackWidth, trackHeight);
layout.rowIterTracks.push_back(iterTrack); layout.rowIterTracks.back() = iterTrack;
layout.rowIterValues.push_back( layout.rowIterValues.back() =
gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn,
sliderRow)); sliderRow);
layout.rowIterResets.push_back( layout.rowIterResets.back() =
gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize);
rowY += sliderRow; rowY += sliderRow;
} }
} }