diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 7ea9580..f56b00b 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -521,7 +521,7 @@ namespace jdi * 默认值是 **Solarised Light**:这是项目历史默认,用户 2026-10-29 明确要求 * 新增其它主题时"默认的主题色维持现状"。 */ - ThemeKind m_themeKind = ThemeKind::SOLARIZED_LIGHT; + ThemeKind m_themeKind = ThemeKind::LIGHT; UiFonts m_fonts; /**< 界面字体。 */ PopupMenu m_menu; /**< 与主题一致的自绘弹出菜单。 */ ColorPickerPopup m_colorPicker; /**< 与主题一致的浮动拾色器。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index f793783..fc6b971 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -662,8 +662,14 @@ namespace jdi // 信号列表与绘图区的动画(目标信号滚动 / 分组折起展开 / 网格滚动到可视区 / //「回到最上方」按钮的淡入淡出):仅在动画进行中保持定时重绘。 + // ⚠️ 另外还要覆盖「面板缓存键被冻结」的情形(用户 2026-11-02 报告:拖完横向光标 / + // 参数滑块并松开后,统计面板一直停在“统计中…”,直到鼠标移到面板上或做其它操作): + // 交互期间面板缓存键被冻结(复用旧像素),而**鼠标停下后不会再有重绘请求** ⇒ + // 永远看不到最终数字。`PanelRefreshPending()` 为真时保持定时器,等 + // `kEdgeScanInteractiveIdleMs` 过去、某个非交互帧把面板按最新数字重画一次。 if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() || - m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive()) + m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive() || + m_plot.PanelRefreshPending()) { if (!m_scrollTimerActive) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index af7c97e..0c5dbd2 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1271,6 +1271,16 @@ namespace jdi /** @brief 「回到最上方」按钮是否正在淡入/淡出。 */ bool BackToTopAnimationActive() const { return m_backToTopAnimating; } + /** + * @brief 上一条绘制帧是否**冻结了实验性功能面板的缓存键**(交互帧)。 + * + * 冻结期间面板显示的是旧像素(统计数字停在“统计中…”),而鼠标停下后 + * **不会再有重绘请求** ⇒ 面板永远停在旧数字。宿主应在为真时武装短周期定时器, + * 直到某个非交互帧把面板按最新数字重画一次(见 `Application::Render`)。 + */ + bool PanelRefreshPending() const { return m_panelKeysFrozen; } + + /** * @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。 */ @@ -1806,6 +1816,23 @@ namespace jdi gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ int rows = 0; /**< 统计行数(每条可见信号一行)。 */ + + /** @brief 表格的一列(左边界 + 宽度,客户区坐标)。 */ + struct TableColumn + { + float left = 0.0f; + float width = 0.0f; + }; + /** + * @brief 「中心轴边沿计数」表格的**表头行**(固定不滚动;内容区在它下面)。 + * + * 该面板自 2026-11-02 起改为表格:表头三列 = 信号名 / 上升沿计数 / 下降沿计数, + * 下面每条**已勾选**的信号占一行,行底色 = 该信号的配色,三列与表头严格对齐。 + * 单击行(非复选框)展开该信号自己的两行参数滑块。 + */ + gd::RectF tableHeader; + /** @brief 表格三列的几何(`0` 信号名 / `1` 上升沿计数 / `2` 下降沿计数)。 */ + std::array tableColumns{}; /** * @brief 「逐条目」面板的矩形(MEAN / CENTER / PEAK 面板共用同一套排布)。 * @@ -2944,6 +2971,28 @@ namespace jdi */ int m_meanRowHoverLane = -1; SignalRef m_meanRowHoverRef; + /** + * @brief 悬停所在的**面板种类**(MEAN / CENTER / PEAK)。 + * + * ⚠️ 必须区分:同一条信号可能在多个面板里各有一行(均值滤波、中心轴边沿计数、 + * 弱峰调整都用同一套条目栏),只比 `(lane, ref)` 会把**所有**面板的同名行一起点亮, + * 并且无法判断“该高亮哪一条预览曲线”。 + * + * 用户 2026-11-02 报告:只开弱峰调整 + 中心轴边沿计数时悬停**毫无反应**; + * 一旦再开均值滤波,三个面板悬停都只高亮均值滤波曲线 —— + * 根因就是绘制侧只给 MEAN 分支传了 `highlight`,而悬停状态没有记录面板种类。 + */ + EdgePanelKind m_meanRowHoverKind = EdgePanelKind::MEAN; + + /** + * @brief 上一帧绘制面板时是否使用了「冻结的缓存键」(交互期间)。 + * + * 用途:冻结期间面板显示的是上一帧像素(统计数字停留在“统计中…”), + * 而**鼠标停下后不会再有重绘请求** ⇒ 面板永远停在旧数字 + *(用户 2026-11-02 报告:“松开左键后一直统计中…,动一下鼠标或悬停面板才更新”)。 + * 宿主每帧检查它:为真就武装一个短周期定时器,直到某个非交互帧重画面板为止。 + */ + mutable bool m_panelKeysFrozen = false; // ---------------------------------------------------------------- 自由标记 diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index dcc0918..30086a9 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -700,6 +700,37 @@ namespace jdi return (!complete && rising == 0 && falling == 0); } + /** + * @brief 表格单元格里的**纯数字**(「中心轴边沿计数」表格用)。 + * + * 表头已经写了「上升沿计数 / 下降沿计数」,单元格里再写「上升沿 N」就重复了; + * 未算完时在数字后追加 `…`(与旧的两列排版同一个语义)。 + */ + inline std::wstring EdgeCountCellText(std::size_t value, bool complete) + { + std::wstring text = std::to_wstring(value); + if (!complete) + { + text += L" …"; + } + return text; + } + + /** @brief 「中心轴边沿计数」表格的表头文本(信号名 / 上升沿计数 / 下降沿计数)。 */ + inline const wchar_t* EdgeCenterTableHeader(int column) + { + switch (column) + { + case 0: + return L"信号名"; + case 1: + return L"上升沿计数"; + case 2: + default: + return L"下降沿计数"; + } + } + /** * @brief 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。 * diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index f0af772..36701db 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -1950,7 +1950,8 @@ namespace jdi { int hoverLane = -1; SignalRef hoverRef; - const EdgePanelKind hoverKind = static_cast(hoverPanelKind); + EdgePanelKind hoverKind = + static_cast(hoverPanelKind); if (overEdgePanel && m_tab != nullptr && panelLane >= 0 && (hoverKind == EdgePanelKind::MEAN || hoverKind == EdgePanelKind::PEAK || hoverKind == EdgePanelKind::CENTER)) @@ -1977,13 +1978,12 @@ namespace jdi } } } - if (hoverLane != m_meanRowHoverLane || !(hoverRef == m_meanRowHoverRef)) + if (hoverLane != m_meanRowHoverLane || !(hoverRef == m_meanRowHoverRef) || + (hoverLane >= 0 && hoverKind != m_meanRowHoverKind)) { m_meanRowHoverLane = hoverLane; m_meanRowHoverRef = hoverRef; - // 预览曲线在部分绘制路径里属于"静态场景"(整幅重绘会缓存它), - // 因此悬停状态一变就作废场景缓存 —— 只在"进出某一行"时发生,不是每帧。 - InvalidateSceneCache(); + m_meanRowHoverKind = hoverKind; changed = true; } } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 88745a1..b276d61 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -745,6 +745,13 @@ namespace jdi boxes.Add(layout.edit, 3); boxes.Add(layout.button, 3); boxes.Add(layout.content, 3); + boxes.Add(layout.tableHeader, 3); + for (const EdgePanelLayout::TableColumn& column : layout.tableColumns) + { + boxes.Add(gd::RectF(column.left, layout.tableHeader.Y, column.width, + layout.tableHeader.Height), + 4); + } boxes.Add(layout.scrollTrack, 3); boxes.Add(layout.scrollThumb, 4); boxes.Add(layout.opacityTrack, 3); @@ -783,11 +790,24 @@ namespace jdi } // 收尾:解除面板裁剪并**复位层号**(宿主后续组件依赖它)。 // 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)必须比**最上面板**再高一层。 + // ⚠️ **只在它们真的会被绘制时上报**(用户 2026-11-02 报告:关闭分组后仍能看到大量 + // 残留的布局边界框):这些矩形在 `Layout()` 里就无条件算好,但绘制时会按可见性 / + // 编辑态门控。旧实现照样上报 ⇒ 空绘图区里同时出现“不存在的滚动条 / 回到最上方 / + // 输入框”的框。 boxes.SetClip(gd::RectF()); boxes.SetLayer(topOverlayLayer); - boxes.Add(m_scrollBarRect, 5); - boxes.Add(BackToTopRect(), 5); - boxes.Add(EditFieldRect(), 5); + if (m_scrollBarVisible) + { + boxes.Add(m_scrollBarRect, 5); + } + if (BackToTopVisible()) + { + boxes.Add(BackToTopRect(), 5); + } + if (m_editTarget != PlotEditTarget::NONE) + { + boxes.Add(EditFieldRect(), 5); + } boxes.SetLayer(baseLayer); } @@ -855,6 +875,19 @@ namespace jdi if (m_lanes.empty()) { InvalidateSceneCache(); + // ⚠️⚠️ **必须清掉上一帧登记的叠加层/命中几何**(用户 2026-11-02 报告:关闭整个分组后 + // 仍能看到大量残留的布局边界框):本分支直接 return,`PaintLaneLegend` / + // `PaintCursor` / `PaintFreeMarker` 都不会跑,于是 `m_legendHits` / + // `m_cursorLabelRects` / `m_cursorSpanRect` / `m_freeMarkerHits` 里的**旧矩形** + // 会一直保留(布局边界会继续把它们画出来,甚至命中测试也会命中已不存在的元素)。 + m_legendHits.clear(); + m_cursorLabelRects.clear(); + m_cursorSpanRect = gd::RectF(); + m_freeMarkerHits.clear(); + m_decorRects.clear(); + m_panelOrder.clear(); + m_panelSlots.clear(); + m_panelValid = false; PaintEmptyState(g); // ⚠️ 空状态(尚未勾选任何信号)也必须绘制提示浮层:加载失败 / 解析异常正是 // **最需要**非模态提示的场景,若在此提前返回,宿主的错误信息将永远不可见。 @@ -2074,7 +2107,12 @@ namespace jdi const int saveState = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); // 面板条目栏的悬停高亮(用户 2026-10-30 要求):鼠标停在哪一行,就把那条**预览曲线** - // 画一圈光晕 + 加粗。`m_meanRowHoverLane/Ref` 由 `OnMouseMove` 维护。 + // 画一圈光晕 + 加粗。`m_meanRowHoverLane/Ref/Kind` 由 `OnMouseMove` 维护。 + // + // ⚠️⚠️ **必须按面板种类分别判定**(用户 2026-11-02 报告):三个面板(均值滤波 / + // 中心轴边沿计数 / 弱峰调整)都有“信号条目栏”,同一条信号可能同时在三个面板里各有一行。 + // 旧实现只给 MEAN 分支传 `highlight` ⇒ 只开弱峰 + 中心轴时悬停毫无反应;三个全开时 + // 悬停任何一个面板都只高亮均值滤波那条。现在只高亮“鼠标所在面板”对应的曲线。 const bool hoverActive = (m_meanRowHoverLane == laneIndex) && m_meanRowHoverRef.IsValid(); for (const SignalRef& ref : tabLane.signals) { @@ -2083,13 +2121,16 @@ namespace jdi { continue; } - const bool hovered = hoverActive && (ref == m_meanRowHoverRef); + const bool hoveredThisRow = hoverActive && (ref == m_meanRowHoverRef); if (centerOn) { const Lane::EdgeCenterItem* centerItem = tabLane.FindEdgeCenterItem(ref); if (centerItem != nullptr) { - PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations); + const bool highlight = + hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::CENTER); + PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations, + highlight); } } if (meanFilterOn) @@ -2097,7 +2138,9 @@ namespace jdi const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); if (item != nullptr) { - PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations, hovered); + const bool highlight = + hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::MEAN); + PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations, highlight); } } if (weakPeakOn) @@ -2111,7 +2154,9 @@ namespace jdi const int boostIterations = std::max( kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, item->iterations)); - PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations, hovered); + const bool highlight = + hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::PEAK); + PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations, highlight); } } } @@ -3500,6 +3545,9 @@ namespace jdi // 这些叠加层都画在**面板之下**、面板本身不透明且贴回时只贴圆角形状之内,因此交互期间 // 直接复用面板像素是安全的;鼠标停下后键自动恢复,面板重画一次即可。 const bool interactivePanelKey = InputIsInteractive(); + // 供宿主判断“冻结过后需要补一帧”(冻结期间面板显示的是旧像素,鼠标停下后 + // 不会再有重绘请求 ⇒ 统计数字会永远停在“统计中…”)。 + m_panelKeysFrozen = interactivePanelKey; if (interactivePanelKey) { mix(0x9E3779B9ull); @@ -3534,6 +3582,9 @@ namespace jdi // “鼠标处于条目栏上方时对应曲线/条目需要高亮提示”)。 mix(static_cast(m_meanRowHoverLane + 2)); mixRef(m_meanRowHoverRef); + // 悬停所在的**面板种类**同样改变面板像素:同一条信号可能在多个面板里各有一行, + // 只有“这一行属于本面板”时该面板才叠强调色(否则悬停一个面板会把其它面板的同名行一起点亮)。 + mix(static_cast(m_meanRowHoverKind) + 2); // 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。 // ⚠️⚠️ **交互期间必须用固定占位值**:拖动光标时时间每帧都在变,键一变就要重画全部面板。 @@ -3665,6 +3716,12 @@ namespace jdi mixRect(layout.showDelta); mixRect(layout.showEnum); mixRect(layout.content); + mixRect(layout.tableHeader); + for (const EdgePanelLayout::TableColumn& column : layout.tableColumns) + { + mixFloat(column.left); + mixFloat(column.width); + } mixRect(layout.scrollTrack); mixRect(layout.scrollThumb); mixRect(layout.opacityTrack); @@ -3817,12 +3874,12 @@ namespace jdi } else if (kind == EdgePanelKind::CENTER && !interactiveKey) { + // 「中心轴边沿计数」是**表格**:每个“已勾选统计”的行都会显示两列数字, + // 因此 `rowRefs` 里凡是 `FindEdgeCenterItem != nullptr` 的都要把数字混进键 + //(⚠️ 表格没有 `rowStats` 了,不能再按它门控 —— 否则数字变化不会让键变化, + // 面板会永远停在“统计中…”)。 for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { - if (rowIndex >= layout.rowStats.size() || layout.rowStats[rowIndex].Width <= 0.0f) - { - continue; // 与绘制时的条件一致:只有这一排才显示统计数字 - } const SignalRef& ref = layout.rowRefs[rowIndex]; const ResolvedSignal resolved = ResolveSignal(ref); const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); @@ -4484,12 +4541,12 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // ⚠️ `itemized` 只表示**旧的逐条目排布**(现在只剩「弱峰调整」)。 - // 「均值滤波」自 2026-10-30 起、**「中心轴边沿计数」自 2026-11-02 起**都有自己的 - // **可滚动分支**(`if (listStyle)` 内)。若它们仍被算作 `itemized`,旧分支会 - // **再画一遍**每行的名称 + 复选框 —— 且旧分支**没有任何裁切**,滚动时这些元素就会 - // 画到标题栏上(用户 2026-10-30 报告“元素超出浮动面板”的根因)。 - const bool listStyle = (meanFilter || centerAxis || weakPeak); + // ⚠️ 「均值滤波」「弱峰调整」= 可滚动的分组列表(条目栏 + 展开后的两行滑块); + // **「中心轴边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数),单独一支。 + // 三者都**不能**再走旧的逐条目分支:那个分支没有任何裁切,滚动时会画到标题栏上 + //(用户 2026-10-30 报告“元素超出浮动面板”的根因)。 + const bool listStyle = (meanFilter || weakPeak); + const bool centerTable = centerAxis; const bool hasButton = (meanFilter || weakPeak); { if (index >= count) @@ -4721,6 +4778,284 @@ namespace jdi if (!layout.collapsed) { + // 「中心轴边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)**固定不滚动**, + // 下面每条可见信号一行,行底色 = 该信号的配色,三列与表头严格对齐。 + // 单击行(非复选框)展开 / 收起该信号自己的两行参数滑块。 + if (centerTable) + { + // 复选框自绘(在信号色叠底上用**自适应对比色**)。 + const auto paintTableCheck = [this, &g](const gd::RectF& rect, bool checked, + const UiInk& ink, + std::uint32_t background) { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const float radius = 2.5f * m_scale; + if (!checked) + { + UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); + return; + } + UiFillRoundRect(g, rect, radius, ink.text); + const float left = rect.X + rect.Width * 0.22f; + const float middleX = rect.X + rect.Width * 0.44f; + const float right = rect.X + rect.Width * 0.78f; + const float highY = rect.Y + rect.Height * 0.30f; + const float lowY = rect.Y + rect.Height * 0.72f; + UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, + 1.6f * m_scale); + UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); + }; + + // ---- 表头(固定;不参与滚动)---- + { + UiFillRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->pressed); + UiDrawRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->border, 1.0f); + for (int column = 0; column < 3; ++column) + { + const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + const float textLeft = + (column == 0) ? (cell.left + 4.0f * m_scale) : cell.left; + UiTextClipped(g, EdgeCenterTableHeader(column), m_fonts->uiSmall, + gd::RectF(textLeft, layout.tableHeader.Y, cell.width, + layout.tableHeader.Height), + m_theme->textMuted, + (column == 0) ? TextAlign::LEFT : TextAlign::CENTER, + layout.tableHeader); + } + } + + const int tableSave = g.Save(); + // 行内容必须裁到内容区:滚动时行会越出可视范围。 + g.SetClip(layout.content, gd::CombineModeIntersect); + + const float valueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + const float sliderLabelColumn = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + if (rowIndex >= layout.rowHeaders.size() || + rowIndex >= layout.rowChecks.size() || + rowIndex >= layout.rowTriangles.size()) + { + break; + } + const SignalRef& ref = layout.rowRefs[rowIndex]; + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF& row = layout.rowHeaders[rowIndex]; + const Lane::EdgeCenterItem* item = tabLane.FindEdgeCenterItem(ref); + const bool checked = (item != nullptr); + // 整行(含展开的滑块)的纵向范围决定是否还需要绘制。 + float rowBottom = row.Y + row.Height; + if (rowIndex < layout.rowIterTracks.size() && + layout.rowIterTracks[rowIndex].Height > 0.0f) + { + rowBottom = std::max(rowBottom, layout.rowIterTracks[rowIndex].Y + + layout.rowIterTracks[rowIndex].Height); + } + if (rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Height > 0.0f) + { + rowBottom = std::max(rowBottom, layout.rowLevelTracks[rowIndex].Y + + layout.rowLevelTracks[rowIndex].Height); + } + if (rowBottom <= layout.content.Y || + row.Y >= layout.content.Y + layout.content.Height) + { + continue; + } + + const std::uint32_t signalColor = StyleColor(*resolved.style); + const UiInk ink = UiContrastInk(signalColor); + // 行底色 = 该信号的配色(用户 2026-11-02 要求);未勾选的行用普通底 + // + 弱文本,表示“尚未参与统计”。 + if (checked) + { + UiFillRoundRect(g, row, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + } + else + { + UiFillRoundRect(g, row, 3.0f * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, row, 3.0f * m_scale, m_theme->border, 1.0f); + } + if (m_meanRowHoverLane == static_cast(index) && + m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) + { + UiFillRoundRect(g, row, 3.0f * m_scale, + WithAlpha(m_theme->accent, 0x2E)); + } + + const std::uint32_t rowInk = checked ? ink.text : m_theme->text; + + // 展开 / 收起三角(仅已勾选时存在)。 + const gd::RectF& triangle = layout.rowTriangles[rowIndex]; + if (triangle.Width > 0.0f && checked) + { + UiDrawTriangleIcon(g, triangle, rowInk, + item->collapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + } + + // 信号名(第一列):超出列宽时省略。 + const gd::RectF& nameRect = layout.rowNameRects[rowIndex]; + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, + std::max(8.0f, nameRect.Width)), + m_fonts->uiSmall, nameRect, rowInk, TextAlign::LEFT, + layout.content); + + // 复选框:位于名称单元的右端。 + paintTableCheck(layout.rowChecks[rowIndex], checked, ink, + checked ? signalColor : m_theme->cardBg); + + // 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。 + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (checked) + { + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, + item->points, item->iterations, EdgeRangeActive(), + EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + } + for (int column = 1; column < 3; ++column) + { + const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + std::wstring text; + if (checked) + { + text = EdgeCountCellText((column == 1) ? rising : falling, complete); + } + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(cell.left, row.Y, cell.width, row.Height), rowInk, + TextAlign::CENTER, layout.content); + } + + // 展开后的两行参数滑块(均值点数 / 迭代次数)。 + if (!checked || item->collapsed) + { + continue; + } + const auto paintTableSlider = [&](const gd::RectF& track, + const gd::RectF& valueRect, + const gd::RectF& resetRect, + const wchar_t* label, int current, + int minimum, int maximum, bool active) { + if (track.Width <= 0.0f) + { + return; + } + const float labelLeft = + track.X - 6.0f * m_scale - sliderLabelColumn; + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + sliderLabelColumn, track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + const double ratio = + (maximum > minimum) + ? (static_cast(current - minimum) / + static_cast(maximum - minimum)) + : 0.0; + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, std::to_wstring(current), m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, + valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect( + g, gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, + hot ? m_theme->accent : m_theme->textMuted, m_scale); + }; + + const int pointsFloor = 0; // 中心轴:0 = 不平滑(保持旧语义) + const int pointsMax = std::max( + pointsFloor, + std::min(kMeanFilterPointsMaxLimit, item->maxPoints)); + const int pointsValue = + std::max(pointsFloor, std::min(pointsMax, item->points)); + const int iterationsMax = + std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, item->maxIterations)); + const int iterationsValue = + std::max(kMeanFilterIterationsMin, + std::min(iterationsMax, item->iterations)); + const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == ref); + const bool iterationsActive = + (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && + (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref); + if (rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f) + { + paintTableSlider( + layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kMeanFilterPointsSliderLabel, pointsValue, pointsFloor, pointsMax, + pointsActive); + } + if (rowIndex < layout.rowIterTracks.size() && + layout.rowIterTracks[rowIndex].Width > 0.0f) + { + paintTableSlider( + layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kMeanFilterIterationsSliderLabel, iterationsValue, + kMeanFilterIterationsMin, iterationsMax, iterationsActive); + } + } + + 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); + } + } + + // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 @@ -4821,14 +5156,12 @@ namespace jdi } const std::uint32_t signalColor = StyleColor(*resolved.style); const UiInk ink = UiContrastInk(signalColor); - // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems` - //(两者都是 `Lane::MeanFilterItem`,字段完全一致);「弱峰调整」看 `weakPeakItems`。 + // 「均值滤波」看 `meanFilterItems`、「弱峰调整」看 `weakPeakItems` + //(中心轴边沿计数已改为独立的表格分支)。 const Lane::WeakPeakItem* peakItem = weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; const Lane::MeanFilterItem* item = - weakPeak ? nullptr - : (meanFilter ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref)); + weakPeak ? nullptr : tabLane.FindMeanFilterItem(ref); const bool checked = weakPeak ? (peakItem != nullptr) : (item != nullptr); const bool rowCollapsed = weakPeak ? (peakItem != nullptr && peakItem->collapsed) @@ -4840,7 +5173,10 @@ namespace jdi UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 - if (m_meanRowHoverLane == static_cast(index) && m_meanRowHoverRef == ref) + // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, + // 只比 (lane, ref) 会把别的面板的同名行一起点亮。 + if (m_meanRowHoverLane == static_cast(index) && + m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) { UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(m_theme->accent, 0x2E)); @@ -4873,21 +5209,6 @@ namespace jdi continue; } - // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && rowIndex < layout.rowStats.size() && - layout.rowStats[rowIndex].Width > 0.0f) - { - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, - item->iterations, EdgeRangeActive(), EdgeRangeMin(), - EdgeRangeMax(), rising, falling, complete); - paintCounts(layout.rowStats[rowIndex], rising, falling, complete, - layout.content); - } - - // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 const auto paintMeanSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, @@ -5034,7 +5355,8 @@ namespace jdi } else { - const int pointsFloor = centerAxis ? 0 : kMeanFilterPointsMin; + // 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。 + const int pointsFloor = kMeanFilterPointsMin; const int pointsMax = std::max(pointsFloor, std::min(kMeanFilterPointsMaxLimit, item->maxPoints)); @@ -5448,9 +5770,9 @@ namespace jdi { continue; } - if (freeMarker || listStyle) + if (freeMarker || listStyle || centerTable) { - // 这两种排布已在上面画完(各自有自己的可滚动分支)。 + // 这些排布已在上面画完(各自有自己的可滚动 / 表格分支,含裁切)。 continue; } if (rowY + rowHeight * 2.0f <= layout.content.Y || @@ -5927,4 +6249,4 @@ namespace jdi UiDrawLine(g, cx, cy - depth, cx + halfWidth, cy - depth * 0.15f, ink, stroke); } -} // namespace jdi +} // namespace jdi \ No newline at end of file diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 7b80884..240c454 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -28,12 +28,12 @@ namespace jdi const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起全部是**可滚动的分组列表** - //(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行)。 + // 「均值滤波」「弱峰调整」= 可滚动的分组列表(信号色叠底条目栏 + 右侧复选框 + + // 展开后的两行滑块);**「中心轴边沿计数」= 表格**(信号名 / 上升沿计数 / 下降沿计数)。 // ⚠️ 旧式「逐条目」排版(名称 + 复选框 + 参数行,无滚动)已彻底删除: // 上百个信号时它会算出上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** // ⇒ 每一帧整块重画 ⇒ 所有交互卡顿(用户 2026-11-02 报告)。 - const bool listStyle = (meanFilter || centerAxis || weakPeak); + const bool listStyle = (meanFilter || weakPeak); // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” // + 光标区间尾缀)。 @@ -106,13 +106,31 @@ namespace jdi const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); statRisingWidth = std::max( statRisingWidth, - std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), + std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)), pendingWidth)); statFallingWidth = std::max(statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); + UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete))); } - if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) + if (centerAxis) + { + // 「中心轴边沿计数」= **表格**(信号名 / 上升沿计数 / 下降沿计数): + // 宽度 = 复选框 + 名称列 + 两列计数(列宽取「表头文字」「实际数字」的较大者)+ 滚动条。 + const float headerRising = + UiMeasureTextWidth(m_fonts->uiSmall, EdgeCenterTableHeader(1)); + const float headerFalling = + UiMeasureTextWidth(m_fonts->uiSmall, EdgeCenterTableHeader(2)); + const float risingColumn = std::max(headerRising, statRisingWidth) + 10.0f * m_scale; + const float fallingColumn = std::max(headerFalling, statFallingWidth) + 10.0f * m_scale; + const float gap = kEdgePanelStatColumnGap * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + + kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale + + std::min(nameColumnWidth, kEdgePanelNameMaxWidth * m_scale) + + gap + risingColumn + gap + fallingColumn + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); + } + else if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) { // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 @@ -254,13 +272,10 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。 - // ⚠️ 「均值滤波」从 2026-10-30 起改为**可滚动的分组列表**(信号色叠底标题栏 + - // 展开后的两行滑块),与「自由标记」同一套浏览方式 ⇒ 不再走这里的 `itemized` 分支。 - // ⚠️ 「中心轴边沿计数」从 2026-11-02 起同样改为**可滚动的分组列表**(用户明确要求 - // “改造为与均值滤波相同的风格”)⇒ 也不走 `itemized`。它没有「构建为新信号」按钮, - // 但内容多一行「上升沿 / 下降沿」统计。 - const bool listStyle = (meanFilter || centerAxis || weakPeak); + // 「均值滤波」与「弱峰调整」共用同一套**可滚动分组列表**排布(信号色叠底条目栏 + + // 展开后的两行滑块);「中心轴边沿计数」自 2026-11-02 起单独走**表格**分支(`centerTable`)。 + const bool listStyle = (meanFilter || weakPeak); + const bool centerTable = centerAxis; const bool hasButton = (meanFilter || weakPeak); const bool enabled = meanFilter ? lane.meanFilter : (weakPeak ? lane.weakPeak @@ -318,12 +333,46 @@ namespace jdi const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; float meanContentHeight = 0.0f; float height = 0.0f; - if (listStyle) + float tableHeaderHeight = 0.0f; + if (centerTable) { - // 「均值滤波 / 中心轴边沿计数 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏** - //(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 - // 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块; - // 「均值滤波」与「弱峰调整」= 两行滑块。 + // 「中心轴边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)固定不滚动, + // 下面每条**已勾选**的信号占一行(行底色 = 信号配色),单击行展开两行参数滑块。 + // 默认只显示表格行(`EnsureEdgeCenterItem` 新建条目时 `collapsed = true`)。 + const float rowH = kEdgePanelMeanRowHeight * m_scale; + float content = 0.0f; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + if (item == nullptr && !resolved.visible) + { + continue; + } + content += rowH; + if (item != nullptr && !item->collapsed) + { + content += weakPeakSliderRow * 2.0f; + } + } + meanContentHeight = content; + tableHeaderHeight = rowH; + const float budget = + m_plotRect.Height - (titleHeight + pad + tableHeaderHeight + bottomPad); + const float maxContent = + std::max(3.0f * rowH, std::min(kFreeMarkerContentMaxHeight * m_scale, + std::max(3.0f * rowH, budget))); + const float view = std::min(content, maxContent); + height = titleHeight + pad + tableHeaderHeight + view + bottomPad; + } + else if (listStyle) + { + // 「均值滤波 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏** + //(内容总是显示,用于勾选/取消)+(勾选且未收起时)两行参数滑块。 // 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = weakPeakSliderRow; @@ -344,17 +393,12 @@ namespace jdi } else { - const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref) - : lane.FindEdgeCenterItem(ref); + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); expanded = (item != nullptr && !item->collapsed); } if (expanded) { content += sliderRow * 2.0f; - if (centerAxis) - { - content += groupRow; // 统计行(上升沿 / 下降沿) - } } } meanContentHeight = content; @@ -554,10 +598,164 @@ namespace jdi { return layout; } - if (listStyle) + if (centerTable) { - // 「均值滤波 / 中心轴边沿计数」:可滚动内容区 - //(逐信号的信号色叠底条目栏 + 展开后的统计行与两行滑块)+(均值滤波)底部按钮。 + // 「中心轴边沿计数」= **表格**:表头固定、内容区可滚动。 + // 列:① 信号名(含复选框与展开三角)② 上升沿计数 ③ 下降沿计数。 + const float rowH = kEdgePanelMeanRowHeight * m_scale; + const float sliderRow = kWeakPeakSliderRowHeight * m_scale; + 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.edgeCenterScroll, maxScroll)); + const float innerWidth = + std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + 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); + } + + // 三列:名称列吃掉剩余宽度,两列计数按内容宽度固定右对齐 —— 表头与数据行严格对齐。 + const float closeSize = kEdgePanelMeanCheckSize * m_scale; + const float gap = kEdgePanelStatColumnGap * m_scale; + const float contentLeft = layout.content.X; + // 计数列宽 = 「表头文字」「当前数字」的较大者(与 `EdgePanelWidth` 同源)。 + float risingCol = UiMeasureTextWidth(m_fonts->uiSmall, EdgeCenterTableHeader(1)) + 10.0f * m_scale; + float fallingCol = UiMeasureTextWidth(m_fonts->uiSmall, EdgeCenterTableHeader(2)) + 10.0f * m_scale; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr || + item == nullptr) + { + continue; + } + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), + rising, falling, complete); + risingCol = std::max(risingCol, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)) + + 10.0f * m_scale); + fallingCol = std::max( + fallingCol, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete)) + + 10.0f * m_scale); + } + const float nameColumn = + std::max(20.0f * m_scale, innerWidth - gap - risingCol - gap - fallingCol); + layout.tableColumns[0].left = contentLeft; + layout.tableColumns[0].width = nameColumn; + layout.tableColumns[1].left = contentLeft + nameColumn + gap; + layout.tableColumns[1].width = risingCol; + layout.tableColumns[2].left = layout.tableColumns[1].left + risingCol + gap; + layout.tableColumns[2].width = fallingCol; + + // 参数行(两行滑块)的列宽:与均值滤波同一套算式。 + const float labelColumn = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + const float valueColumn = UiMeasureTextWidth(m_fonts->monoSmall, L"100000"); + const float resetSize = kWeakPeakResetButtonSize * m_scale; + const float resetGap = kWeakPeakResetGap * m_scale; + const float trackHeight = kWeakPeakTrackHeight * m_scale; + const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale; + const float resetLeft = contentLeft + innerWidth - resetSize; + const float trackWidth = + std::max(10.0f * m_scale, + (resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft); + + float rowY = layout.content.Y - scroll; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + if (item == nullptr && !resolved.visible) + { + continue; + } + // 未勾选(`item == nullptr`)的行也列出来:它是“启用统计”的唯一入口 + //(表格模式下没有独立的开关;点行内复选框即启用 / 取消)。 + const bool checked = (item != nullptr); + const bool expanded = (item != nullptr && !item->collapsed); + layout.rowRefs.push_back(ref); + layout.rowHeaders.push_back(gd::RectF(contentLeft, rowY, innerWidth, rowH)); + layout.rowNameRects.push_back(gd::RectF( + layout.tableColumns[0].left + kEdgePanelDisclosureWidth * m_scale + 4.0f * m_scale, + rowY, std::max(10.0f, nameColumn - closeSize - kEdgePanelDisclosureWidth * m_scale), + rowH)); + layout.rowTriangles.push_back( + checked ? gd::RectF(contentLeft + 2.0f * m_scale, + rowY + (rowH - kEdgePanelRowTriangleSize * m_scale) * 0.5f, + kEdgePanelRowTriangleSize * m_scale, + kEdgePanelRowTriangleSize * m_scale) + : gd::RectF()); + layout.rowChecks.push_back(gd::RectF( + contentLeft + nameColumn - closeSize - 2.0f * m_scale, + rowY + (rowH - closeSize) * 0.5f, closeSize, closeSize)); + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + rowY += rowH; + + if (!expanded) + { + 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()); + continue; + } + const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, + trackWidth, trackHeight); + layout.rowLevelTracks.push_back(pointsTrack); + layout.rowLevelValues.push_back( + gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, + sliderRow)); + layout.rowLevelResets.push_back( + gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); + rowY += sliderRow; + const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, + trackWidth, trackHeight); + layout.rowIterTracks.push_back(iterTrack); + layout.rowIterValues.push_back( + gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, + sliderRow)); + layout.rowIterResets.push_back( + gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); + rowY += sliderRow; + } + } + else if (listStyle) + { + // 「均值滤波 / 弱峰调整」:可滚动内容区(信号色叠底条目栏 + 展开后的两行滑块) + // +(均值滤波 / 弱峰调整)底部按钮。 const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; if (hasButton) { @@ -573,8 +771,7 @@ namespace jdi std::max(0.0f, height - titleHeight - pad - pad - 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 : lane.edgeCenterScroll); + const float storedScroll = meanFilter ? lane.meanFilterScroll : lane.weakPeakScroll; 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)); @@ -632,8 +829,7 @@ namespace jdi } else { - const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref) - : lane.FindEdgeCenterItem(ref); + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); checked = (item != nullptr); itemCollapsed = (item != nullptr && item->collapsed); } @@ -667,12 +863,7 @@ namespace jdi layout.rowIterResets.push_back(gd::RectF()); continue; } - // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && !layout.rowStats.empty()) - { - layout.rowStats.back() = gd::RectF(contentLeft, rowY, innerWidth, groupRow); - rowY += groupRow; - } + // 「中心轴边沿计数」已改为独立表格分支,这里不再需要额外的统计行。 const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); layout.rowLevelTracks.push_back(pointsTrack); diff --git a/Components/Shared/Src/Theme.cpp b/Components/Shared/Src/Theme.cpp index 2c4189b..6a858e2 100644 --- a/Components/Shared/Src/Theme.cpp +++ b/Components/Shared/Src/Theme.cpp @@ -227,7 +227,7 @@ namespace jdi }()); /** @brief 全局「当前主题」指针(由宿主设置,见 `SetCurrentTheme`)。 */ - const Theme* g_currentTheme = &kSolarizedLight; + const Theme* g_currentTheme = &kLight; } double SrgbLuminance(std::uint32_t argb) @@ -347,15 +347,16 @@ namespace jdi { switch (kind) { - case ThemeKind::LIGHT: - return kLight; case ThemeKind::DARK: return kDark; case ThemeKind::SOLARIZED_DARK: return kSolarizedDark; case ThemeKind::SOLARIZED_LIGHT: - default: return kSolarizedLight; + case ThemeKind::LIGHT: + default: + // 默认主题 = Light(用户 2026-11-02 要求;旧默认是 Solarised Light)。 + return kLight; } } @@ -363,26 +364,26 @@ namespace jdi { switch (kind) { - case ThemeKind::LIGHT: - return L"Light"; case ThemeKind::DARK: return L"Dark"; case ThemeKind::SOLARIZED_DARK: return L"Solarised Dark"; case ThemeKind::SOLARIZED_LIGHT: - default: return L"Solarised Light"; + case ThemeKind::LIGHT: + default: + return L"Light"; } } const Theme& CurrentTheme() { - return (g_currentTheme != nullptr) ? *g_currentTheme : kSolarizedLight; + return (g_currentTheme != nullptr) ? *g_currentTheme : kLight; } void SetCurrentTheme(const Theme* theme) { - g_currentTheme = (theme != nullptr) ? theme : &kSolarizedLight; + g_currentTheme = (theme != nullptr) ? theme : &kLight; } const std::uint32_t* SignalPalette(int& count) diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index e7b3395..a632ac3 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -324,6 +324,19 @@ namespace jdi */ SignalStyle* StyleOf(const SignalRef& ref); + /** + * @brief 当前是否存在**样式编辑目标**(选中信号且其样式可解析)。 + * + * ⚠️ 样式区的色块 / 样式 / 调整 / 线宽 / 透明度这些控件的矩形是**无条件**由 + * `LayoutStyleControls()` 算出来的(布局与"是否有目标"无关)。若不在使用处按本判据 + * 门控,就会出现用户 2026-11-02 报告的现象:什么都没选中时,打开「显示布局边界」 + * 能看到那一排控件的框,鼠标移到「系数/偏移 | 镜像 | 绝对值」上还会变成手型。 + * + * 使用处:`BuildBoxes`(几何上报)、`QueryCursor`(手型光标)。 + * 绘制与命中早已按 `StyleOf(SelectedRef()) != nullptr` 门控,这里只是补齐另外两处。 + */ + bool HasStyleTarget() const; + /** * @brief 获取当前选中信号(跨标签页共享,因此切换视图后选中项保持一致)。 * @return 信号引用。 diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 21d1bd0..112e64f 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -100,34 +100,46 @@ namespace jdi boxes.Add(ScrollThumbRect(), 3); // ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ────────────────── - for (std::size_t index = 0; index < m_styleRowRects.size(); ++index) + // ⚠️ **只在存在样式编辑目标时登记控件几何**(用户 2026-11-02 报告): + // `LayoutStyleControls()` 无条件算出这些矩形,而绘制在“没有目标”时只画占位文字。 + // 旧实现照样上报 ⇒ 空面板上打开「显示布局边界」会看到一整排并不存在的控件框。 + const bool hasStyleTarget = HasStyleTarget(); + if (hasStyleTarget) { - boxes.Add(m_styleRowRects[index], 2); + for (std::size_t index = 0; index < m_styleRowRects.size(); ++index) + { + boxes.Add(m_styleRowRects[index], 2); + } + for (const gd::RectF& swatch : m_colorRects) + { + boxes.Add(swatch, 3); + } + boxes.Add(m_customChipRect, 3); + for (const gd::RectF& mode : m_modeRects) + { + boxes.Add(mode, 3); + } + for (const gd::RectF& adjust : m_adjustRects) + { + boxes.Add(adjust, 3); + } + boxes.Add(m_widthTrackRect, 3); + boxes.Add(m_widthValueRect, 3); + boxes.Add(m_widthResetRect, 3); + boxes.Add(m_opacityTrackRect, 3); + boxes.Add(m_opacityValueRect, 3); + boxes.Add(m_opacityResetRect, 3); } - for (const gd::RectF& swatch : m_colorRects) - { - boxes.Add(swatch, 3); - } - boxes.Add(m_customChipRect, 3); - for (const gd::RectF& mode : m_modeRects) - { - boxes.Add(mode, 3); - } - for (const gd::RectF& adjust : m_adjustRects) - { - boxes.Add(adjust, 3); - } - boxes.Add(m_widthTrackRect, 3); - boxes.Add(m_widthValueRect, 3); - boxes.Add(m_widthResetRect, 3); - boxes.Add(m_opacityTrackRect, 3); - boxes.Add(m_opacityValueRect, 3); - boxes.Add(m_opacityResetRect, 3); // ── 「目标」行(样式区顶部,展示当前选中信号) ──────────────────── + // 这一行**无论是否选中**都会绘制(未选中时显示占位文本),所以本体总是登记; + // 但信号名 / GX 徽标只在实际有目标时存在。 boxes.Add(m_targetRowRect, 2); - boxes.Add(m_targetNameRect, 3); - boxes.Add(m_targetBadgeRect, 3); + if (hasStyleTarget) + { + boxes.Add(m_targetNameRect, 3); + boxes.Add(m_targetBadgeRect, 3); + } // ── 列表里**当前可见**的行,以及每行内部的元素 ──────────────────── // 被滚出列表的行不登记,否则边界会糊到列表区之外。 @@ -1454,6 +1466,17 @@ namespace jdi return (m_dataSetWritable != nullptr) ? m_dataSetWritable->Style(ref) : nullptr; } + bool SignalPanel::HasStyleTarget() const + { + // 与 `PaintStyleSection` / `OnLButtonDown` 的判据**完全同源**:选中信号且样式可解析。 + if (m_tab == nullptr || m_dataSet == nullptr) + { + return false; + } + const SignalRef& selected = SelectedRef(); + return selected.IsValid() && StyleOf(selected) != nullptr; + } + const SignalRef& SignalPanel::SelectedRef() const { static const SignalRef fallback; diff --git a/Components/SignalPanel/Src/SignalPanelEdit.cpp b/Components/SignalPanel/Src/SignalPanelEdit.cpp index ca1aeb4..27072b2 100644 --- a/Components/SignalPanel/Src/SignalPanelEdit.cpp +++ b/Components/SignalPanel/Src/SignalPanelEdit.cpp @@ -55,21 +55,27 @@ namespace jdi // 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。 const float fx = static_cast(x); const float fy = static_cast(y); - // 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。 - UpdateTargetRowGeometry(); - if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) || - (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))) + // ⚠️ 样式区控件只在**存在编辑目标**时才可点(用户 2026-11-02 报告:没有选中信号时, + // 鼠标掠过「系数/偏移 | 镜像 | 绝对值」仍会变成手型,而点了什么也不会发生)。 + if (HasStyleTarget()) { - return LoadCursorW(nullptr, IDC_HAND); - } - if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || - UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || - UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || - UiPointInRect(m_opacityValueRect, fx, fy) || - (m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) || - (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy))) - { - return LoadCursorW(nullptr, IDC_HAND); + // 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。 + UpdateTargetRowGeometry(); + if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) || + (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || + UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || + UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || + UiPointInRect(m_widthValueRect, fx, fy) || + UiPointInRect(m_opacityValueRect, fx, fy) || + (m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) || + (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy))) + { + return LoadCursorW(nullptr, IDC_HAND); + } } return nullptr; } diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp index 60abe4c..2bc2e01 100644 --- a/Components/TabBar/Src/TabBar.cpp +++ b/Components/TabBar/Src/TabBar.cpp @@ -42,9 +42,9 @@ namespace jdi m_scale = scale; } - /** @brief 更新这一格的显示状态(名称 / 激活 / 编辑态 / 关闭按钮几何)。 */ + /** @brief 更新这一格的显示状态(名称 / 激活 / 编辑态 / 关闭按钮与文本几何)。 */ void SetState(std::wstring name, bool active, gd::RectF closeRect, bool editing, - const TextEdit* edit, gd::RectF editRect) + const TextEdit* edit, gd::RectF editRect, gd::RectF textRect) { const bool changed = (m_name != name) || (m_active != active) || (m_editing != editing); m_name = std::move(name); @@ -53,6 +53,7 @@ namespace jdi m_editing = editing; m_edit = edit; m_editRect = editRect; + m_textRect = textRect; if (changed) { Invalidate(Bounds()); @@ -111,8 +112,14 @@ namespace jdi 1.0f * m_scale, m_theme->accent); } - const float textLeft = rect.X + kTabPadding * m_scale; - const float textWidth = m_closeRect.X - kCloseGap * m_scale - textLeft; + // ⚠️ 文本可用矩形由 `TabBar::ArrangeWidgets` **统一下发**(与排布同一份几何), + // 不能用控件自己的 `m_scale` 从 `m_closeRect` 反推:标签宽度恰好等于 + // `2*pad + 文本 + gap + close`(没有任何余量),而控件缓存的 `m_scale` + // 一旦与排布时的不一致(DPI 切换),反推出来的宽度就会少几像素 ⇒ + // `UiEllipsize` 把最后一个字符换成省略号。 + const gd::RectF textRect = m_textRect; + const float textLeft = textRect.X; + const float textWidth = textRect.Width; if (m_editing && m_edit != nullptr) { @@ -155,8 +162,7 @@ namespace jdi bool m_active = false; /**< 是否激活。 */ bool m_editing = false; /**< 是否处于重命名编辑态。 */ const TextEdit* m_edit = nullptr; /**< 编辑模型(编辑态时非空)。 */ - gd::RectF m_editRect; /**< 编辑框矩形。 */ - gd::RectF m_closeRect; /**< 关闭按钮矩形。 */ + gd::RectF m_editRect; /**< 编辑框矩形。 */ gd::RectF m_textRect; /**< 文本可用矩形(由 `TabBar` 下发)。 */ gd::RectF m_closeRect; /**< 关闭按钮矩形。 */ }; /** @brief 「+」新建按钮控件。 */ @@ -277,7 +283,19 @@ namespace jdi void TabBar::SetScale(float scale) { - m_scale = (scale > 0.1f) ? scale : 1.0f; + const float next = (scale > 0.1f) ? scale : 1.0f; + if (next == m_scale) + { + return; + } + m_scale = next; + // ⚠️⚠️ **缩放变化必须重建控件树**(用户 2026-11-02 报告:把窗口从 125% 显示器拖到 + // 100% 显示器后,唯一的标签页文本变成省略号,双击重命名再取消才恢复): + // 每个 `TabItemWidget` 在 `Configure` 时缓存了当时的 `m_scale`,而它内部用这个缩放 + // 反推“可用文本宽度”(`closeRect.X - kCloseGap*scale - (rect.X + kTabPadding*scale)`)。 + // 标签宽度是按新缩放排的(恰好 = 2*pad + 文本 + gap + close,**没有任何余量**), + // 而控件仍用旧缩放扣边距 ⇒ 可用宽度比实测文本宽度小几像素 ⇒ `UiEllipsize` 吃掉末字。 + RebuildWidgets(); } void TabBar::SetTabs(const std::vector& names) @@ -448,9 +466,14 @@ namespace jdi TabItemWidget* widget = static_cast(children[index].get()); const TabItem& tab = m_tabs[index]; const bool editing = (static_cast(index) == m_editIndex); + // 文本可用矩形:与排布用同一份 `m_scale`,不再由控件自行反推。 + const gd::RectF textRect(tab.rect.X + kTabPadding * m_scale, tab.rect.Y, + std::max(8.0f, tab.closeRect.X - kCloseGap * m_scale - + (tab.rect.X + kTabPadding * m_scale)), + tab.rect.Height); widget->Arrange(tab.rect); widget->SetState(tab.name, static_cast(index) == m_activeIndex, tab.closeRect, editing, - editing ? &m_edit : nullptr, EditRect()); + editing ? &m_edit : nullptr, EditRect(), textRect); } static_cast(children[m_tabs.size()].get())->Arrange(m_addRect); static_cast(children[m_tabs.size() + 1].get())->Arrange(m_area); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index abaa3e8..70700e5 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -9186,3 +9186,127 @@ make release / make debug 零 warning 取像素做 A/B 时要**避开圆角 8px**,否则会误报 7 个像素。 - ⚠️ `git diff --numstat` 出现整文件级别的增删时要先怀疑行尾/BOM 被 PowerShell 改掉; 本轮所有写回都用 `[System.IO.File]::WriteAllText(... UTF8Encoding($false))`。 + +--- + +## 2026-11-02 第二十一轮(本机):预览曲线按面板高亮 + 统计数字"卡在统计中" + 中心轴面板改表格 + 默认主题 Light + 标签页 DPI + 布局边界幽灵元素 + +> 六件事,用户逐条报告。全部用离屏探针验证(`.preflight/p21`、`p22`、`p23`,`SW_HIDE` + 合成消息, +> 不占用鼠标/键盘/屏幕);`p20`(第二十轮的探针)回归全绿。 + +### 1. ⭐⭐ 预览曲线悬停高亮:只认「鼠标所在的那个面板」 + +现象:只开「弱峰调整 + 中心轴边沿计数」时,悬停信号条目栏**毫无反应**;三个实验性功能全开时, +悬停**任何一个**面板都只看到「均值滤波」那条曲线被高亮。 + +根因(`PaintLaneDerivedOverlays`):三条曲线的绘制分支里**只有 MEAN 分支传了 `highlight`**, +CENTER 分支压根没传(默认 false);而悬停状态只记录了 `(lane, ref)`,**没有记录面板种类** —— +同一条信号在三个面板里各有一行,无法区分"该高亮哪一条"。 + +修法: +* 新增 `m_meanRowHoverKind`(`EdgePanelKind`),`OnMouseMove` 与 `(lane, ref)` 一起维护; +* 每条曲线的高亮条件 = `悬停行 == 本行 && 悬停面板种类 == 本曲线的面板种类`; +* 条目栏的强调色叠加同样加上 `m_meanRowHoverKind == kind`(否则悬停一个面板会把其它面板的 + 同名行一起点亮); +* 面板缓存键(`PanelGlobalKey`)混入悬停种类(它直接改变面板像素)。 + +### 2. ⭐⭐ 统计数字"松手后永远停在统计中…" + +现象:按住横向光标快速拖动、并在**鼠标继续快速移动时**松开左键 ⇒ 面板一直显示"统计中…", +直到鼠标移到面板上或做别的操作才更新(中心轴面板拖参数滑块后松手同理)。 + +根因:交互期间(拖拽 / 鼠标 140ms 内移动过)面板缓存键被**冻结**(`InputIsInteractive()`), +面板直接贴上一帧像素 —— 这是第十九轮为"拖动跟手"刻意做的。但**冻结结束后没有任何重绘请求**: +鼠标停了就没有 `WM_MOUSEMOVE`,统计也早已完成(不会再 `RequestEdgeScanFrame`)⇒ +最后一次绘制仍是冻结帧,界面上永远停在旧文字。 + +修法(补一帧): +* `PanelGlobalKey()` 里记录 `m_panelKeysFrozen = interactivePanelKey`(`mutable`); +* 新增公开查询 `PlotView::PanelRefreshPending()`; +* `Application::Render` 末尾:`PanelRefreshPending()` 为真时也保持 `kScrollTimerId` 定时器, + `WM_TIMER` 走既有的"只失效绘图区"分支 ⇒ 冻结窗口结束后的下一帧按最新数字重画面板, + 之后 `m_panelKeysFrozen` 变假、定时器自动停。 + +### 3. 「中心轴边沿计数」面板改为**表格** + +表头固定不滚动:**信号名 | 上升沿计数 | 下降沿计数**;下面每条可见信号一行,行底色 = 该信号的 +配色(未勾选的行用普通底色 + 空复选框,表示"尚未参与统计"),三列与表头严格对齐;内容超高时 +右侧出现滚动条;单击行(非复选框)展开该信号自己的两行参数滑块(均值点数 / 迭代次数)。 + +实现要点: +* `EdgePanelLayout` 新增 `tableHeader` + `tableColumns[3]`(`TableColumn{left,width}`); +* `EdgePanelLayoutFor` 新增 `centerTable` 分支(CENTER 不再走 `listStyle`); +* 新增 `EdgeCenterTableHeader(col)` / `EdgeCountCellText(value, complete)`(单元格只写数字, + 未算完时追加 `…`); +* `Lane::EnsureEdgeCenterItem` 新建条目时 **`collapsed = true`**(表格行默认收起参数滑块); +* `PanelSlotKey` 的 CENTER 分支**不能再按 `rowStats` 门控**(表格没有 rowStats 了)—— + 改为"凡 `FindEdgeCenterItem != nullptr` 的行都把 (rising, falling, complete) 混进键", + 否则数字变化不会让键变化、面板永远停在"统计中…"; +* 表格复用原有的 `rowHeaders/rowChecks/rowTriangles/rowNameRects/rowLevel*/rowIter*`, + 因此**交互代码不用改**(勾选切换、整行展开收起、滑块拖动、滚动条 / 滚轮全部沿用)。 + +### 4. 默认主题改为 **Light** + +`Application::m_themeKind = ThemeKind::LIGHT`;`ThemeFor()` 的 `default`、`ThemeName()` 的 +`default`、`CurrentTheme()` / `SetCurrentTheme(nullptr)` 的兜底也一并改为 `kLight` +(避免"宿主未设置时回落到 Solarised Light"的不一致)。 + +### 5. 标签页文本在 DPI 切换后被省略 + +现象:主屏 125% 打开,拖到 100% 副屏后「视图 1」变成「视图…」,双击改名再取消就恢复正常。 + +根因:标签宽度恰好等于 `2*pad + 文本 + gap + close`(**没有任何余量**),而 +`TabItemWidget` 在 `Configure({fonts, theme, scale})` 时**缓存了当时的 scale**,绘制时用它 +从 `closeRect` 反推"可用文本宽度"。`TabBar::SetScale` 只改自己的 `m_scale`、**不重建控件树** +⇒ DPI 切换后控件仍用旧缩放扣边距(`14*1.25` vs `14*1.0`、`6*1.25` vs `6*1.0`)⇒ +可用宽度比实测文本窄约 4.5px ⇒ `UiEllipsize` 吃掉末字。双击改名会走到重建路径,于是"又好了"。 + +修法(两层): +* `TabBar::SetScale` 在缩放**真的变化**时 `RebuildWidgets()`(与 `Toolbar::SetScale` 的 + `m_treeDirty = true` 同一思路); +* 更根本的一层:**文本可用矩形改由 `TabBar::ArrangeWidgets` 统一下发** + (`TabItemWidget::SetState(..., textRect)`),绘制侧不再自己反推 —— 几何单一来源, + 以后不会再被"缩放/取整"这类差异咬到。 + +### 6. 布局边界里的"幽灵元素" + +* ⭐ **样式区控件在未选中信号时也上报几何、也接受光标**:`LayoutStyleControls()` 无条件算出 + 色块/样式/调整/线宽/透明度等矩形,而绘制在"没有目标"时只画占位文字。 + `OnLButtonDown` 与 `OnMouseMove` 早已按 `StyleOf(SelectedRef())` 门控, + 但 **`BuildBoxes` 与 `QueryCursor` 没有** ⇒ 打开「显示布局边界」能看到整排控件框, + 鼠标掠过「系数/偏移 | 镜像 | 绝对值」还会变手型。 + 修法:新增 `SignalPanel::HasStyleTarget()`(与绘制/命中同一判据), + `BuildBoxes` 只在有目标时登记这些控件(「目标」行本体总是登记), + `QueryCursor` 同样只在有目标时给手型。 +* ⭐ **关闭整个分组后残留大量边界框**:`Paint` 在 `m_lanes.empty()` 时直接 return, + `PaintLaneLegend / PaintCursor / PaintFreeMarker` 都不跑 ⇒ + `m_legendHits / m_cursorLabelRects / m_cursorSpanRect / m_freeMarkerHits / m_panelSlots` + 里的**旧矩形**一直留着(布局边界继续画、命中测试也会命中已不存在的元素)。 + 修法:空分支里显式清空这些几何并清 `m_panelOrder`。 +* 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)以前**无条件上报**矩形 + (即使没画)⇒ 现在按 `m_scrollBarVisible` / `BackToTopVisible()` / `m_editTarget != NONE` + 门控。 + +### 7. 验证 + +``` +p21.exe 9/9 无幽灵样式区框、空样式区无手型;选中信号后框与手型恢复; + 关闭分组后 lanes=0、legendHits=0、绘图区内只剩 4 个真实元素框 +p22.exe 14/14 默认主题 = Light;表格表头存在/列不重叠/内容在表头之下; + 行底色 = 信号配色;交互帧冻结 ⇒ 空闲窗口后自动解冻(宿主补帧); + 悬停 CENTER 行高亮中心轴曲线且种类记为 CENTER;悬停 PEAK 面板记为 PEAK; + **DPI 往返(96→120→96)后标签文本墨迹宽度 38 vs 实测 40(A/B 去掉重建 ⇒ 25)** +p23.exe 7/7 60 信号:表格限高/可滚动/像素缓存命中;勾选框切换统计;整行展开收起; + 展开后出现滑块轨道;内容滚动生效 +p20 / p20c / p20d 全绿(第二十轮的面板限高 / 弱峰列表 / 统计推进 / 组名折起) +make release / make debug 零 warning;Release 813,568 B(< 1MB 红线) +``` + +### 8. 踩坑 + +* ⚠️ 用 `#define private public` 的离屏探针里,"读像素来判断文字是否被省略"必须**与同一 DPI + 下实测的文本宽度比较**:跨 DPI 比较字号本来就不同(本机主屏 125%,进程启动即 120 DPI)。 +* ⚠️ 探针里两个面板默认都叠在右上角 ⇒ `TopPanelAtPoint` 只返回最上层那个, + 要验证"某个面板的悬停"必须先把另一块的 `panel.x/y` 挪开。 +* ⚠️ 改完 `TabBar` 的 `SetState` 签名要同步 `ArrangeWidgets` 唯一调用点; + 探针 A/B 时**先确认 `make` 真的重编了**(过滤输出只看 error 会漏掉"没重编")。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 419918f..964a5fb 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1349,6 +1349,10 @@ namespace jdi } EdgeCenterItem item; item.ref = ref; + // 「中心轴边沿计数」面板自 2026-11-02 起是**表格**(信号名 / 上升沿计数 / 下降沿计数): + // 新增条目默认**只显示一行表格行**,参数滑块(均值点数 / 迭代次数)收起,单击该行展开。 + // (均值滤波 / 弱峰调整的条目仍默认展开,它们的语义未变。) + item.collapsed = true; edgeCenterItems.push_back(item); return edgeCenterItems.back(); }