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

@@ -1178,6 +1178,142 @@ namespace jdi
return false;
}
// ---- 面板的「排版类别」与「滚动几何」的唯一来源 --------------------------------
//
// ⚠️⚠️ 为什么要有这一层:面板的**分类布尔量**过去在三个地方各写一份
// (`EdgePanelWidth` / `EdgePanelLayoutFor` / `PaintEdgeCursor`),
// 每份都是 6 个 `kind == XXX` + 3 条组合判断。新增一种面板时若只改其中两处,
// 就会出现"宽度按 A 类算、绘制按 B 类画"的错位 —— 这正是历史上
// 「元素超出浮动面板」「内容区多出一段空白」「滚动条画在表头上」这类缺陷的共同形态。
// 现在只在这里分派一次,三个消费者都读同一份结果。
//
// ⚠️ 同样**不写 `default:`**:新增 `EdgePanelKind` 而未登记时,
// `-Wswitch`(属 `-Wall`)会直接编译报警。
/**
* @brief 一种浮动面板的排版类别(互斥 + 若干可选部件)。
*
* 三类互斥的内容排版:
* * `listRows` —— 可滚动**分组列表**:条目栏(三角 + 名称 + 复选框)+ 展开后的参数行;
* * `countTable` —— **表格**:固定表头(信号名 / 上升沿 / 下降沿)+ 可滚动行;
* * `markerGroups` —— **分组栏 + 条目**:每个信号一个可折起分组栏 + 固定点条目 + 吸顶。
*/
struct PanelStyle
{
bool listRows = false; /**< 可滚动分组列表(均值 / 中心轴 / 弱峰 / 重采样 / 选区截取)。 */
bool countTable = false; /**< 固定表头的表格(边沿计数)。 */
bool markerGroups = false; /**< 分组栏 + 条目(自由标记)。 */
/** @brief 内容区是否可滚动(三类都是,保留字段以便未来出现「不可滚动」的面板)。 */
bool scrollableContent() const { return listRows || countTable || markerGroups; }
/** @brief 底部是否有「构建为新信号」样式的单一按钮。 */
bool buildButton = false;
/** @brief 底部是否有**两个并排**按钮(选区截取)。 */
bool twoButtons = false;
/** @brief 底部是否有「自由标记」那组固定控件(透明度滑块 + 3 个切换按钮)。 */
bool markerFooter = false;
/**
* @brief 底部是否存在任何内容(按钮 / 滑块 / 开关)。
*
* ⚠️ 与 `buildButton` **不是同一个概念**:「选区截取」有两个并排按钮,
* 但没有「构建为新信号」;而面板高度算式里的「按钮行高」必须两者都为真。
* 曾经把两者当作一回事 ⇒ 截取面板底部少预留一行高度(124 → 87 px),
* 内容区却仍按旧高度计算 ⇒ 面板高度与内容区不一致。
*/
bool footerButtons() const { return buildButton || twoButtons; }
};
/** @brief 取某种面板的排版类别(唯一分类来源)。 */
inline PanelStyle PanelStyleOf(EdgePanelKind kind)
{
PanelStyle style;
switch (kind)
{
case EdgePanelKind::COUNT:
style.countTable = true;
break;
case EdgePanelKind::CENTER:
style.listRows = true;
break;
case EdgePanelKind::MEAN:
style.listRows = true;
style.buildButton = true;
break;
case EdgePanelKind::PEAK:
style.listRows = true;
style.buildButton = true;
break;
case EdgePanelKind::MARK:
style.markerGroups = true;
style.markerFooter = true;
break;
case EdgePanelKind::RESAMPLE:
style.listRows = true;
style.buildButton = true;
break;
case EdgePanelKind::CLIP:
style.listRows = true;
style.twoButtons = true;
break;
}
return style;
}
/**
* @brief 浮动面板**内容区滚动条**的几何与滚动量(三个排版分支共用同一套算式)。
*
* 旧实现把这段算式在 `EdgePanelLayoutFor` 里抄了三遍(表格 / 列表 / 自由标记),
* 三份的差异只在于"总内容高"和"滚动量来自哪个字段"。任何一次微调(比如把
* 最小滑块高度 18 改成别的)都必须记得改三处 —— 典型的"漏一处就错位"。
*
* @param [IN] panelLeft 面板左边界(客户区)。
* @param [IN] contentTop 内容区上边界。
* @param [IN] viewHeight 内容区可视高度。
* @param [IN] contentTotal 内容总高度(可能大于可视高度)。
* @param [IN] scrollValue 期望滚动量(会被夹到 `[0, maxScroll]`)。
* @param [IN] widthWithoutScroll 未扣滚动条时的内宽。
* @param [IN] leftPad 面板左内边距。
* @param [IN] scrollWidth 滚动条宽度(已乘缩放)。
* @param [IN] scale DPI 缩放。
* @return 滚动指标(`scrollable == false` 时 `track/thumb` 为空矩形、`innerWidth` 不扣减)。
*/
struct PanelScrollMetrics
{
bool scrollable = false; /**< 内容是否超高(需要滚动条)。 */
float maxScroll = 0.0f; /**< 最大滚动量。 */
float scroll = 0.0f; /**< 实际滚动量(已夹取)。 */
float innerWidth = 0.0f; /**< 内容可用宽度(需要滚动条时已扣掉它)。 */
gd::RectF track; /**< 滚动条轨道。 */
gd::RectF thumb; /**< 滚动条滑块。 */
};
inline PanelScrollMetrics PanelScrollMetricsFor(float panelLeft, float contentTop, float viewHeight,
float contentTotal, float scrollValue,
float widthWithoutScroll, float leftPad,
float scrollWidth, float scale)
{
PanelScrollMetrics metrics;
metrics.scrollable = contentTotal > viewHeight + 0.5f;
metrics.maxScroll = std::max(0.0f, contentTotal - viewHeight);
metrics.scroll = std::max(0.0f, std::min(scrollValue, metrics.maxScroll));
metrics.innerWidth = std::max(
0.0f, widthWithoutScroll - (metrics.scrollable ? (scrollWidth + 4.0f * scale) : 0.0f));
if (!metrics.scrollable)
{
return metrics;
}
metrics.track = gd::RectF(panelLeft + leftPad + metrics.innerWidth + 4.0f * scale, contentTop,
scrollWidth, viewHeight);
const float thumbHeight = std::max(18.0f * scale, viewHeight * (viewHeight / contentTotal));
const float thumbTop =
contentTop + (viewHeight - thumbHeight) *
((metrics.maxScroll > 0.0f) ? (metrics.scroll / metrics.maxScroll) : 0.0f);
metrics.thumb = gd::RectF(metrics.track.X, thumbTop, scrollWidth, thumbHeight);
return metrics;
}
/**
* @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。
*