diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index b731884..174dd47 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -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 网格下标。 diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index cacc637..5f87656 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -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 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。 * diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index f9604ab..38de880 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -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(m_tab->Lanes().size())) { @@ -1786,31 +1795,26 @@ namespace jdi } Lane& lane = m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(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& items) @@ -2072,18 +2065,7 @@ namespace jdi bool PlotView::SetLaneClipScroll(int laneIndex, float scroll) { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(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& items) @@ -2100,54 +2082,19 @@ namespace jdi } - bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(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) diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index e3782dc..5692f56 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -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(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; } } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 541d80d..d3581de 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -4892,19 +4892,30 @@ namespace jdi //(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。 // 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。 UiSetTextLayer(panelTextLayer + static_cast(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) diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 307c145..5cb0d09 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -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(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(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; // 六列(① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔) // 从左往右排布:每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户要求)。 diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index 0e3dad8..e4e9698 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -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(app.m_dataSet.GroupCount()); ++groupIndex) + { + const SignalGroup* group = app.m_dataSet.GroupAt(groupIndex); + if (group != nullptr) + { + totalSignals += static_cast(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(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}, };