refactor(panels): 浮动面板抽出唯一"面板特征层",消除 3 处分类/滚动几何/滚动设置的重复

背景(用户观察):7 个实验性功能都有自己的浮动面板,观感与骨架高度一致
(标题栏 = 折起三角 + GX 徽标 + 标题 + ✕,内容区可滚动 + 滚动条,底部按钮行),
但每新增一种面板或改动布局,总会出"意料之外"的缺陷。

审计结论:骨架绘制本来只有一份,真正的问题是**同一套"按种类分支"的代码被抄了多份**:

| 重复项 | 位置 | 份数 |
| --- | --- | --- |
| 分类布尔量(listStyle / countTable / hasButton…) | EdgePanelWidth / EdgePanelLayoutFor / PaintEdgeCursor | 3 |
| 滚动条几何 + 滚动量夹取 | 布局里表格 / 列表 / 自由标记三个分支 | 3 |
| 滚动条绘制(6 行) | 绘制里同样三个分支 | 3 |
| SetLane*Scroll 实现 | PlotViewFeatures.cpp | 7 |
| 按种类转发滚动写入 | 滚轮 / 拖滚动条 | 2 |
| 标题文本 | EdgePanelWidth 的三元式链 / 绘制里的三元式链 | 2(内容还不一致!) |

一、新增"面板特征层"(PlotViewInternal.hpp,pvdetail)
* `PanelStyle PanelStyleOf(EdgePanelKind)`:唯一分类来源,`listRows` / `countTable` /
  `markerGroups` + `buildButton` / `twoButtons` / `markerFooter`,`switch` 穷举不写 default
  (新增面板漏登记 ⇒ `-Wswitch` 编译报警)。
  其中专门区分 `footerButtons()`(底部有按钮行)与 `buildButton`(是"构建为新信号")——
  混为一谈会让「选区截取」底部少预留一行高度(本轮实测 124 → 87 px,被像素基线当场抓住)。
* `PanelScrollMetricsFor(...)`:滚动条轨道/滑块几何 + 滚动量夹取 + 内容宽度扣减的唯一实现。
* `PlotView::PanelTitle(EdgePanelKind)`:标题唯一来源。
  ⚠️ 修掉一处**潜伏**错误:`EdgePanelWidth` 的标题链漏了 MARK/CLIP(两者按"边沿计数"量宽),
  目前两种标题恰好等宽所以看不出来,但任何文案改动都会让宽度与标题不匹配。

二、收敛调用点
* `EdgePanelWidth` / `EdgePanelLayoutFor`(表格 / 列表 / 自由标记三分支)/ `PaintEdgeCursor`
  全部改用 `PanelStyleOf` 与 `PanelTitle`;滚动几何改用 `PanelScrollMetricsFor`。
* `EdgePanelLayoutFor` 的 5 层滚动量三元式 → `LanePanelScrollOf(lane, kind)`(该派发点第 33 轮
  就已存在,但布局这里一直没用它)。
* `PaintEdgeCursor` 的滚动条绘制抽成 `paintPanelScrollBar(layout)`,三处调用。
* 7 个 `SetLane*Scroll` 改为一行转发到新的 `SetLanePanelScroll(laneIndex, kind, scroll)`;
  滚轮与"拖滚动条"两条 `switch`/`if` 链改为直接调它(读与写从此同一个入口)。

三、新增离屏检查 `panelscroll`(护栏)
* 对 7 种面板逐一:可滚动时滚到底 / 滚回顶,断言 `scrollOffset` 夹取到 `maxScroll`、
  滑块到达/返回轨道端点、内容区几何不随滚动改变;不可滚动时断言不出现滚动条。
* 用 `MF4-Record.MF4`(566 信号合并到同一网格)实测 6/7 面板可滚(自由标记无固定点,正确)。
  没有这条检查,本次收敛就等于"改完只能靠肉眼"。

验证(本机 GCC 15.2.0 UCRT / 125% DPI)
* `panels -mode compare`:7 个面板的**像素哈希与几何逐项不变**(零差异)。
  这条检查当场抓到了上面 `footerButtons` 的回归,证明基线有效。
* `OffscreenChecks -all`:6 个检查全 PASS,`checks=87 failed=0`。
* `UnitTests`:`checks=219 failed=0`。
* release 全量零 error / 零 warning;产物 788,480 → **787,968 B**(−512)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-04 14:20:57 +08:00
1 parent 5c48bf922e
commit abcf3e6c44
7 files changed
+416 -250

No files matched your search

+120
View File
@@ -477,6 +477,124 @@ namespace jdi
return CheckFailed();
}
/**
* @brief 面板**内容滚动**的行为检查(每一类面板都要能被滚动,且几何随之一致移动)。
*
* 为什么单独一项:面板滚动量过去由 7 个近乎重复的设置函数 + 2 条按种类分派的
* `switch`/`if` 链共同维护,历史上出现过"滚轮能滚、拖滚动条滚不动"与
* "读的是 A 面板的偏移、写进了 B 面板"。本检查对 7 种面板逐一:
* 1. 取布局 → 若可滚动,记录初始 `scrollOffset`;
* 2. 通过**统一入口** `SetLanePanelScroll` 滚到底;
* 3. 断言 `scrollOffset` 等于 `maxScroll`、滑块位置真的移到了轨道底部;
* 4. 再滚回顶部,断言滑块回到轨道顶部。
*/
int CheckPanelScroll(Session& session, const CheckOptions& options)
{
Application& app = session.App();
PlotView& plot = app.m_plot;
// 让面板内容**必定超高**:显示全部信号并合并成一个网格
//(真实场景见历史缺陷:283 条信号合并到同一网格、开实验性功能)。
int totalSignals = 0;
for (int groupIndex = 0; groupIndex < static_cast<int>(app.m_dataSet.GroupCount()); ++groupIndex)
{
const SignalGroup* group = app.m_dataSet.GroupAt(groupIndex);
if (group != nullptr)
{
totalSignals += static_cast<int>(group->signals.size());
}
}
session.ShowFirstSignals(0);
if (session.LaneCount() > 1)
{
app.ExecuteToolbarAction(ToolbarAction::MERGE_LANES);
}
EmitLine("[panelscroll] signals=%d lanes=%d\n", totalSignals, session.LaneCount());
Expect(totalSignals > 0, "loaded at least one signal");
if (app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE)
{
for (int step = 0; step < 4 && app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE; ++step)
{
plot.CycleCursorMode();
}
}
static const EdgePanelKind kinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE,
EdgePanelKind::CLIP};
for (const EdgePanelKind kind : kinds)
{
EnsureLaneFeature(plot, 0, kind);
}
session.RenderFrame();
int scrollableKinds = 0;
for (const EdgePanelKind kind : kinds)
{
const char* name = PanelKindName(kind);
const PlotView::EdgePanelLayout initial = plot.EdgePanelLayoutFor(0, kind);
const float maxScroll =
std::max(0.0f, initial.markerContentHeight - initial.content.Height);
EmitLine("[panelscroll] %-8s content=%.1f view=%.1f maxScroll=%.1f scrollable=%d\n", name,
initial.markerContentHeight, initial.content.Height, maxScroll,
initial.scrollTrack.Width > 0.0f ? 1 : 0);
if (maxScroll <= 1.0f)
{
// 内容没超出可视高度(例如单信号文件):不可滚动是正确行为。
Expect(initial.scrollTrack.Width <= 0.0f,
std::string("no scrollbar when content fits: ") + name);
ExpectNear(initial.scrollOffset, 0.0, 0.5,
std::string("scroll offset stays 0: ") + name);
continue;
}
++scrollableKinds;
Expect(initial.scrollTrack.Width > 0.0f,
std::string("scrollbar appears when content overflows: ") + name);
// 滚到底
Expect(plot.SetLanePanelScroll(0, kind, maxScroll + 500.0f),
std::string("set scroll to bottom: ") + name);
const PlotView::EdgePanelLayout bottom = plot.EdgePanelLayoutFor(0, kind);
ExpectNear(bottom.scrollOffset, maxScroll, 0.5,
std::string("scroll offset clamps to maxScroll: ") + name);
const float travel = initial.scrollTrack.Height - initial.scrollThumb.Height;
ExpectNear(bottom.scrollThumb.Y, initial.scrollTrack.Y + travel, 1.0,
std::string("thumb reaches track bottom: ") + name);
// 内容区几何不能随滚动改变
ExpectNear(bottom.content.Y, initial.content.Y, 0.01,
std::string("content area stays put when scrolling: ") + name);
// 滚回顶部
Expect(plot.SetLanePanelScroll(0, kind, -100.0f),
std::string("set scroll back to top: ") + name);
const PlotView::EdgePanelLayout top = plot.EdgePanelLayoutFor(0, kind);
ExpectNear(top.scrollOffset, 0.0, 0.5, std::string("scroll offset back to 0: ") + name);
ExpectNear(top.scrollThumb.Y, initial.scrollTrack.Y, 1.0,
std::string("thumb back to track top: ") + name);
}
EmitLine("[panelscroll] scrollable kinds: %d / %d\n", scrollableKinds,
static_cast<int>(sizeof(kinds) / sizeof(kinds[0])));
// 用大文件(如 MF4-Record.MF4,283 信号)时必须至少有一类面板真的能滚:
// 否则这条检查会退化成"全是 0 == 0"的空转。
if (totalSignals >= 50)
{
Expect(scrollableKinds > 0, "with many signals at least one panel is scrollable");
}
session.RenderFrame();
if (options.savePng)
{
Expect(SavePng(session.LastFrame(), Join(options.outDir, "panelscroll.png")),
"save panelscroll.png");
}
return CheckFailed();
}
int CheckEdgeStats(Session& session, const CheckOptions& options)
{
session.ShowFirstSignals(1);
@@ -756,6 +874,8 @@ namespace jdi
{"smoke", "offscreen init + load + render + determinism + save png", CheckSmoke},
{"frames", "capture consecutive frames while moving mouse (png + hash + timing)", CheckFrames},
{"panels", "all 7 experiment panels: geometry invariants + pixel baseline A/B", CheckPanels},
{"panelscroll", "panel content scrolling: every kind scrolls, clamps, and keeps content fixed",
CheckPanelScroll},
{"edgestats", "edge counts vs independent reference implementation", CheckEdgeStats},
{"perf", "render / panel layout / edge query timings", CheckPerf},
};