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

+31
View File
@@ -1197,6 +1197,20 @@ namespace jdi
*/
bool SetLaneFreeMarkerOpacity(int laneIndex, float opacity);
/**
* @brief 设置某种面板的**内容滚动偏移**(客户区像素,0 = 顶部)—— 唯一实现。
*
* ⚠️ 下面七个 `SetLane*Scroll` 都只是本函数的一行转发(保留是为了不改调用点与原语义)。
* 新增面板时**只需**在 `LanePanelScrollOf` / `PanelStyleOf` / `PanelTitle` 三处登记,
* 不要再复制一份"取字段 → 夹取 → 比较 → 写回"的实现。
*
* @param [IN] laneIndex 网格下标。
* @param [IN] kind 面板种类。
* @param [IN] scroll 目标滚动量(负值按 0 处理)。
* @return 偏移真的发生变化返回 true。
*/
bool SetLanePanelScroll(int laneIndex, EdgePanelKind kind, float scroll);
/**
* @brief 设置某网格「自由标记」面板内容的滚动偏移(客户区像素,0 = 顶部)。
* @return 偏移真的发生变化返回 true。
@@ -2846,6 +2860,23 @@ namespace jdi
double edgeValue, bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling, bool& complete) const;
/**
* @brief 浮动面板的**标题文本**(唯一来源:宽度计算与绘制共用)。
*
* ⚠️ 过去标题在两处各写一条多层三元式:
* * `EdgePanelWidth` 里的链**漏了「自由标记」与「选区截取」**(两种面板都退化成
* 「边沿计数」的文字来量宽度);
* * `PaintEdgeCursor` 里的链是完整的。
* 目前两种标题恰好与「边沿计数」等宽(或宽度被其它列主导),所以看不出来;
* 但任何一次文案改动都会让"面板宽度与实际标题不匹配"(标题被省略 / 右侧留白)。
*
* 现在宽度与绘制都调本函数,且 `switch` 穷举、不写 `default:`。
*
* @param [IN] kind 面板种类。
* @return 标题(「边沿计数」与「中心轴边沿计数」会追加双光标区间尾缀)。
*/
std::wstring PanelTitle(EdgePanelKind kind) const;
/**
* @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。
* @param [IN] laneIndex 网格下标。
@@ -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 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。
*
+30 -83
View File
@@ -1778,7 +1778,16 @@ namespace jdi
return true;
}
bool PlotView::SetLaneFreeMarkerScroll(int laneIndex, float scroll)
/**
* @brief 设置某种面板的**内容滚动偏移**(唯一实现,公开包装都在下面转发到这里)。
*
* ⚠️ 过去这里是七个几乎逐字重复的函数(自由标记 / 均值滤波 / 重采样 / 选区截取 /
* 中心轴 / 弱峰 / 边沿计数),唯一差别是要写的字段名;调用侧还有两条 `if`/`switch`
* 链再按种类转发一次。新增面板时必须在**三个**地方登记,漏一处就出现
* "滚轮能滚、拖滚动条滚不动"或反之。
* 现在字段一律经 `LanePanelScrollOf(lane, kind)` 取(唯一派发点),本函数是唯一写入点。
*/
bool PlotView::SetLanePanelScroll(int laneIndex, EdgePanelKind kind, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
@@ -1786,31 +1795,26 @@ namespace jdi
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.freeMarkerScroll - clamped) < 0.5f)
{
return false;
}
lane.freeMarkerScroll = clamped;
return true;
}
bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.meanFilterScroll - clamped) < 0.5f)
float& stored = LanePanelScrollOf(lane, kind);
if (std::fabs(stored - clamped) < 0.5f)
{
return false;
}
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
lane.meanFilterScroll = clamped;
stored = clamped;
return true;
}
bool PlotView::SetLaneFreeMarkerScroll(int laneIndex, float scroll)
{
return SetLanePanelScroll(laneIndex, EdgePanelKind::MARK, scroll);
}
bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll)
{
return SetLanePanelScroll(laneIndex, EdgePanelKind::MEAN, scroll);
}
// ============================ 「重采样」 ============================
bool PlotView::SignalDataInterval(const SignalRef& ref, double& maxInterval,
@@ -1963,18 +1967,7 @@ namespace jdi
bool PlotView::SetLaneResampleScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.resampleScroll - clamped) < 0.5f)
{
return false;
}
lane.resampleScroll = clamped;
return true;
return SetLanePanelScroll(laneIndex, EdgePanelKind::RESAMPLE, scroll);
}
bool PlotView::TakePendingResampleBuild(int& laneIndex, std::vector<ResampleBuild>& items)
@@ -2072,18 +2065,7 @@ namespace jdi
bool PlotView::SetLaneClipScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.clipScroll - clamped) < 0.5f)
{
return false;
}
lane.clipScroll = clamped;
return true;
return SetLanePanelScroll(laneIndex, EdgePanelKind::CLIP, scroll);
}
bool PlotView::TakePendingClipBuild(int& laneIndex, std::vector<ClipBuild>& items)
@@ -2100,54 +2082,19 @@ namespace jdi
}
bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) {
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.edgeCenterScroll - clamped) < 0.5f)
{
return false;
}
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
lane.edgeCenterScroll = clamped;
return true;
bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll)
{
return SetLanePanelScroll(laneIndex, EdgePanelKind::CENTER, scroll);
}
bool PlotView::SetLaneWeakPeakScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.weakPeakScroll - clamped) < 0.5f)
{
return false;
}
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
lane.weakPeakScroll = clamped;
return true;
return SetLanePanelScroll(laneIndex, EdgePanelKind::PEAK, scroll);
}
bool PlotView::SetLaneEdgeCountScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.edgeCountScroll - clamped) < 0.5f)
{
return false;
}
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
lane.edgeCountScroll = clamped;
return true;
return SetLanePanelScroll(laneIndex, EdgePanelKind::COUNT, scroll);
}
bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref)
@@ -1819,33 +1819,9 @@ namespace jdi
layout.scrollTrack.Y + travel));
const float ratio = (thumbTop - layout.scrollTrack.Y) / travel;
const float target = ratio * maxScroll;
bool scrolled = false;
// ⚠️ 穷举所有面板种类且**不写 `default:`**:将来新增面板而忘登记时,
// `-Wswitch`(属 `-Wall`)会直接报错,而不是静默落到其它面板上。
// (「自由标记」在上面的独立分支处理,不会到达这里。)
switch (m_meanDragKind)
{
case EdgePanelKind::COUNT:
scrolled = SetLaneEdgeCountScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::CENTER:
scrolled = SetLaneEdgeCenterScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::MEAN:
scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::PEAK:
scrolled = SetLaneWeakPeakScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::MARK:
break;
case EdgePanelKind::RESAMPLE:
scrolled = SetLaneResampleScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::CLIP:
scrolled = SetLaneClipScroll(m_markerScrollDragLane, target);
break;
}
// ⚠️ 唯一写入入口(`SetLanePanelScroll`):旧实现是一条 7 分支 `switch`,
// 新增面板漏登记时拖动滚动条毫无反应(而滚轮那条链可能已经加上了)。
const bool scrolled = SetLanePanelScroll(m_markerScrollDragLane, m_meanDragKind, target);
if (scrolled)
{
m_dragMoved = true;
@@ -3566,30 +3542,10 @@ namespace jdi
const float current = LanePanelScrollOf(wheelLane, hoveredKind);
const float next = current - static_cast<float>(notches * 40.0 * m_scale);
const float target = std::max(0.0f, std::min(next, maxScroll));
switch (hoveredKind)
{
case EdgePanelKind::CENTER:
SetLaneEdgeCenterScroll(panelLane, target);
break;
case EdgePanelKind::PEAK:
SetLaneWeakPeakScroll(panelLane, target);
break;
case EdgePanelKind::COUNT:
SetLaneEdgeCountScroll(panelLane, target);
break;
case EdgePanelKind::RESAMPLE:
SetLaneResampleScroll(panelLane, target);
break;
case EdgePanelKind::CLIP:
SetLaneClipScroll(panelLane, target);
break;
case EdgePanelKind::MEAN:
SetLaneMeanFilterScroll(panelLane, target);
break;
case EdgePanelKind::MARK:
// 「自由标记」由下面的独立分支处理(它的内容高度来自布局)。
break;
}
// ⚠️ 写入也走**同一个**入口(`SetLanePanelScroll`):旧实现这里是
// 一条 7 分支 `switch`,与上面的读取各记一份映射。两处不同步就会
// "读到了 A 面板的偏移、写进了 B 面板"(滚动条跳回顶端 / 滚不动)。
SetLanePanelScroll(panelLane, hoveredKind, target);
return true;
}
}
+21 -28
View File
@@ -4892,19 +4892,30 @@ namespace jdi
//(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。
// 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。
UiSetTextLayer(panelTextLayer + static_cast<int>(panelOrdinal));
// ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`):过去宽度 / 布局 / 本函数各写一份
// 6 个 `kind == XXX` + 3 条组合判断,新增面板漏改一处就错位(见该函数注释)。
const PanelStyle panelStyle = PanelStyleOf(kind);
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE);
const bool clipPanel = (kind == EdgePanelKind::CLIP);
// ⚠️ 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」= 可滚动的分组列表
//(条目栏 + 展开后的参数行);
// **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数),单独一支。
// 它们都**不能**再走旧的逐条目分支:那个分支没有任何裁切,滚动时会画到标题栏上。
const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel || clipPanel);
const bool countTable = (kind == EdgePanelKind::COUNT);
const bool hasButton = (meanFilter || weakPeak || resamplePanel || clipPanel);
const bool listStyle = panelStyle.listRows;
const bool countTable = panelStyle.countTable;
const bool hasButton = panelStyle.buildButton;
// 内容区滚动条:三个排版分支(表格 / 列表 / 自由标记)共用同一段绘制。
// ⚠️ 过去这段 6 行代码在三个分支里各抄一份,任何外观微调(颜色 / 圆角)都要改三处。
const auto paintPanelScrollBar = [this, &g](const EdgePanelLayout& panelLayout) {
if (panelLayout.scrollTrack.Width <= 0.0f)
{
return;
}
UiFillRoundRect(g, panelLayout.scrollTrack, panelLayout.scrollTrack.Width * 0.5f,
WithAlpha(m_theme->border, 0x88));
UiFillRoundRect(g, panelLayout.scrollThumb, panelLayout.scrollThumb.Width * 0.5f,
m_theme->borderStrong);
};
{
if (index >= count)
{
@@ -5230,13 +5241,7 @@ namespace jdi
g.Restore(tableSave);
// 内容滚动条(只在内容超高时出现;表头不参与滚动)。
if (layout.scrollTrack.Width > 0.0f)
{
UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f,
WithAlpha(m_theme->border, 0x88));
UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f,
m_theme->borderStrong);
}
paintPanelScrollBar(layout);
}
// 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 +
@@ -5757,13 +5762,7 @@ namespace jdi
// ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在
// 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告
// "全部启用后没有出现滚动条"的根因)。
if (layout.scrollTrack.Width > 0.0f)
{
UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f,
WithAlpha(m_theme->border, 0x88));
UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f,
m_theme->borderStrong);
}
paintPanelScrollBar(layout);
}
// 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 +
// 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔),
@@ -6073,13 +6072,7 @@ namespace jdi
g.Restore(contentState);
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
if (layout.scrollTrack.Width > 0.0f)
{
UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f,
WithAlpha(m_theme->border, 0x88));
UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f,
m_theme->borderStrong);
}
paintPanelScrollBar(layout);
// ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)----
if (layout.opacityTrack.Width > 0.0f)
+71 -88
View File
@@ -17,6 +17,29 @@
namespace jdi
{
std::wstring PlotView::PanelTitle(EdgePanelKind kind) const
{
// ⚠️ 穷举且不写 `default:`(`-Wswitch` 会在新增面板时报警)。
switch (kind)
{
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);
}
return std::wstring();
}
float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const
{
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
@@ -25,28 +48,18 @@ namespace jdi
return kEdgePanelWidth * m_scale;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
// ⚠️ 排版类别与标题都只从唯一来源取(见 `PanelStyleOf` / `PanelTitle`):
// 过去这三份分类布尔量在宽度 / 布局 / 绘制里各写一遍,新增面板漏改一处就错位。
const PanelStyle panelStyle = PanelStyleOf(kind);
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE);
const bool clipPanel = (kind == EdgePanelKind::CLIP);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」= 可滚动的分组列表
//(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行);
// **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。
// ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出
// 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。
const bool listStyle = (centerAxis || meanFilter || weakPeak || resamplePanel || clipPanel);
const bool countTable = (kind == EdgePanelKind::COUNT);
const bool listStyle = panelStyle.listRows;
const bool countTable = panelStyle.countTable;
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整”
// + 光标区间尾缀)。
const std::wstring title =
meanFilter ? std::wstring(L"均值滤波")
: (weakPeak ? std::wstring(kWeakPeakTitle)
: (resamplePanel
? std::wstring(kResampleTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") +
EdgeRangeSuffix())));
// 标题行 = “GX” 徽标 + 标题(+ 双光标区间尾缀)。
const std::wstring title = PanelTitle(kind);
float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale +
8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) +
kEdgePanelTitleHeight * m_scale;
@@ -214,7 +227,7 @@ namespace jdi
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
if (meanFilter || weakPeak || resamplePanel)
if (panelStyle.buildButton)
{
// 底部按钮行 = 「构建为新信号」(均值滤波 / 弱峰调整 / 重采样有),按按钮文字实测宽度确定下限。
const float buttonWidth =
@@ -330,19 +343,16 @@ namespace jdi
return layout;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
// ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`)—— 过去宽度 / 布局 / 绘制各写一份。
const PanelStyle panelStyle = PanelStyleOf(kind);
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE);
const bool clipPanel = (kind == EdgePanelKind::CLIP);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」共用同一套
//**可滚动分组列表**排布(信号色叠底条目栏 + 展开后的参数行);
//「边沿计数」自 2026-11-02 起走**表格**分支。
//⚠️ 「选区截取」的条目**不可展开**(只需右侧复选框),因此只占一行。
const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel || clipPanel);
const bool countTable = (kind == EdgePanelKind::COUNT);
const bool hasButton = (meanFilter || weakPeak || resamplePanel || clipPanel);
const bool listStyle = panelStyle.listRows;
const bool countTable = panelStyle.countTable;
const bool hasButton = panelStyle.buildButton;
// ⚠️ 面板的「启用 / 展开 / 折起」状态一律通过 `LanePanelEnabled` /
// `LanePanelStateOf` 取值(见 `PlotViewInternal.hpp` 的说明):
// 旧实现是三条 7 层嵌套三元式,新增面板时极容易漏改或写错括号。
@@ -377,7 +387,8 @@ namespace jdi
const float pad = kEdgePanelPad * m_scale;
// 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套**可滚动列表**排布,
// 因此不再有独立的底部编辑行;「边沿计数」走独立的**表格**分支。
const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
const float buttonHeight =
panelStyle.footerButtons() ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
// 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。
// ⚠️⚠️ 「选区截取」的两个按钮(截取为新信号 / 原始信号截取)是**并排在同一行**的
// (见下方的几何赋值),因此 footer 只能按**一行**算。旧实现按 `2 * 按钮高 + 6`
@@ -660,30 +671,21 @@ namespace jdi
const float scrollW = kFreeMarkerScrollWidth * m_scale;
const float viewHeight =
std::max(0.0f, height - titleHeight - pad - tableHeaderHeight - pad);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float scroll = std::max(0.0f, std::min(lane.edgeCountScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
// 滚动几何走唯一实现(见 `PanelScrollMetricsFor`)。
const PanelScrollMetrics scrollMetrics =
PanelScrollMetricsFor(left, top + titleHeight + pad + tableHeaderHeight, viewHeight,
meanContentHeight, LanePanelScrollOf(lane, kind), width - pad * 2.0f,
pad, scrollW, m_scale);
const float innerWidth = scrollMetrics.innerWidth;
layout.tableHeader =
gd::RectF(left + pad, top + titleHeight + pad, innerWidth, tableHeaderHeight);
layout.content = gd::RectF(left + pad, layout.tableHeader.Y + tableHeaderHeight, innerWidth,
viewHeight);
layout.markerContentHeight = meanContentHeight;
layout.scrollOffset = scroll;
if (scrollable)
{
// 滚动条只占**内容区**(不覆盖固定的表头,表头永远干净可读)。
layout.scrollTrack =
gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW,
viewHeight);
const float thumbHeight =
std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight));
const float thumbTop =
layout.content.Y +
(viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f);
layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight);
}
layout.scrollOffset = scrollMetrics.scroll;
// 滚动条只占**内容区**(不覆盖固定的表头,表头永远干净可读)。
layout.scrollTrack = scrollMetrics.track;
layout.scrollThumb = scrollMetrics.thumb;
// 三列:名称列吃掉剩余宽度,两列计数按内容宽度固定 —— 表头与数据行严格对齐。
const float gap = kEdgePanelStatColumnGap * m_scale;
const float contentLeft = layout.content.X;
@@ -723,7 +725,7 @@ namespace jdi
layout.tableColumns[1].width = risingCol;
layout.tableColumns[2].left = layout.tableColumns[1].left + risingCol + gap;
layout.tableColumns[2].width = fallingCol;
float rowY = layout.content.Y - scroll;
float rowY = layout.content.Y - scrollMetrics.scroll;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
@@ -792,31 +794,20 @@ namespace jdi
// 条目区下方与按钮之间留出一片空白(用户 2026-11-03 两次报告)。
const float viewHeight =
std::max(0.0f, height - titleHeight - pad - bottomPad - footerHeight);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float storedScroll = meanFilter ? lane.meanFilterScroll
: (weakPeak ? lane.weakPeakScroll
: (resamplePanel ? lane.resampleScroll
: (clipPanel ? lane.clipScroll
: lane.edgeCenterScroll)));
const float scroll = std::max(0.0f, std::min(storedScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
// ⚠️ 滚动量一律走 `LanePanelScrollOf(lane, kind)`(唯一派发点):
// 过去这里是一条 5 层三元式(均值/弱峰/重采样/截取/中心轴),
// 而「边沿计数」不在其中 —— 它与 `LanePanelScrollOf` 各记一份映射,
// 新增面板时极易只改一处(滚轮能滚、拖动滚动条滚不动之类的错位)。
const PanelScrollMetrics scrollMetrics =
PanelScrollMetricsFor(left, top + titleHeight + pad, viewHeight, meanContentHeight,
LanePanelScrollOf(lane, kind), width - pad * 2.0f, pad, scrollW,
m_scale);
const float innerWidth = scrollMetrics.innerWidth;
layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight);
layout.markerContentHeight = meanContentHeight;
layout.scrollOffset = scroll;
if (scrollable)
{
layout.scrollTrack =
gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW,
viewHeight);
const float thumbHeight =
std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight));
const float thumbTop =
layout.content.Y +
(viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f);
layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight);
}
layout.scrollOffset = scrollMetrics.scroll;
layout.scrollTrack = scrollMetrics.track;
layout.scrollThumb = scrollMetrics.thumb;
// 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。
const float labelColumn =
@@ -846,7 +837,7 @@ namespace jdi
std::max(10.0f * m_scale,
(resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft);
float rowY = layout.content.Y - scroll;
float rowY = layout.content.Y - scrollMetrics.scroll;
// ⚠️ 「选区截取」在**未启用双光标**时不生成任何信号行:面板只显示一句居中提示
// (用户 2026-11-03 明确要求:不应显示信号名条目、更不应能滚动)。
const bool clipRowsVisible = !clipPanel || clipReadyHere;
@@ -1065,26 +1056,18 @@ namespace jdi
{
totalContent += groupRow;
}
const bool scrollable = totalContent > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, totalContent - viewHeight);
const float scroll = std::max(0.0f, std::min(lane.freeMarkerScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
// 滚动几何走唯一实现(与表格 / 列表分支同一套算式)。
const PanelScrollMetrics scrollMetrics =
PanelScrollMetricsFor(left, top + titleHeight + pad, viewHeight, totalContent,
LanePanelScrollOf(lane, kind), width - pad * 2.0f, pad, scrollW,
m_scale);
const float innerWidth = scrollMetrics.innerWidth;
layout.content =
gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight);
layout.markerContentHeight = totalContent;
if (scrollable)
{
layout.scrollTrack =
gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW,
viewHeight);
const float thumbHeight =
std::max(18.0f * m_scale, viewHeight * (viewHeight / totalContent));
const float thumbTop =
layout.content.Y +
(viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f);
layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight);
}
layout.scrollTrack = scrollMetrics.track;
layout.scrollThumb = scrollMetrics.thumb;
const float scroll = scrollMetrics.scroll;
// 六列(① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔)
// 从左往右排布:每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户要求)。
+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},
};