From 38fbd4b465d635d1be0a598ad834aaf3d3d1b254 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 4 Nov 2026 15:13:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor(panels):=20=E6=8B=86=E5=88=86=20PaintE?= =?UTF-8?q?dgeCursor=20=E7=9A=84=E4=B8=89=E6=AE=B5=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E7=BB=98=E5=88=B6=E5=88=B0=E7=8B=AC=E7=AB=8B=E5=88=86=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:Code Review 报告 §12.7 建议 2。`PaintEdgeCursor` 原本约 1900 行,其中 "按面板排版类别绘制内容"占 1000 行以上,三段(表格 / 列表 / 自由标记)几乎互不相干, 却共享同一个函数的全部局部变量 —— 任何一段的改动都要在 1900 行里定位, 且很容易误用另一段的变量。 改动 * 新增 `Components/PlotView/Src/PlotViewPanelPaint.cpp`(1016 行,机械搬移、零逻辑改动): * `PaintPanelCountTableRows` —— 边沿计数表格:固定表头 + 可滚动数据行。 * `PaintPanelListRows` —— 列表式:条目栏 + 展开后的参数行(均值/中心轴/弱峰/重采样/截取)。 * `PaintPanelMarkerGroups` —— 自由标记:分组栏 + 表头 + 条目 + 吸顶。 每个函数**只接收它真正需要的东西**(`layout` + `tabLane` + 少数几个参数), 不再"整个函数都看得见"。 * `PaintEdgeCursor` 从 1900 → 约 900 行;三处块替换为一行调用, 骨架(边框 / 标题栏 / 折起三角 / GX 徽标 / ✕)与底部按钮**仍留在一处**(对所有面板同源)。 * 滚条绘制抽为 `pvdetail::PaintPanelScrollBar(g, layout, theme)`(三个分支共用)。 顺带修掉报告里承诺但未落地的一处 * `PaintEdgeCursor` 的标题仍是 7 层三元式,与 `EdgePanelWidth` 里**另一条**链并存 (那条漏了 MARK/CLIP)。现统一为 `PanelTitle(kind)` 唯一来源。 验证(像素基线全部不变,证明是纯搬移) * `panels -mode compare` → 7 个面板像素哈希 + 几何**逐项不变**。 * `OffscreenChecks -all` → 6/6 PASS,`checks=108 failed=0`。 * `UnitTests` → `checks=260 failed=0`。 * `panelscroll`(MF4,566 信号)→ 6/7 面板可滚,行为不变。 * release 全量零 error / 零 warning;产物 786,944 → **786,432 B**(−512)。 --- Components/PlotView/Inc/PlotView.hpp | 44 +- Components/PlotView/Inc/PlotViewInternal.hpp | 22 + Components/PlotView/Src/PlotViewPaint.cpp | 1011 +--------------- .../PlotView/Src/PlotViewPanelPaint.cpp | 1016 +++++++++++++++++ 4 files changed, 1097 insertions(+), 996 deletions(-) create mode 100644 Components/PlotView/Src/PlotViewPanelPaint.cpp diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 45c2a8f..1449abc 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1946,7 +1946,6 @@ namespace jdi * @param [IN] lanes 网格绘制信息集合。 */ void PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes); - /** * @brief 确保面板图层缓存位图可用(尺寸不符时重建)。 * @return 可用返回 true。 @@ -2213,6 +2212,49 @@ namespace jdi }; }; + /** + * @brief 「边沿计数」表格面板的**行内容**(固定表头 + 可滚动数据行)。 + * + * 面板骨架(边框 / 标题栏 / ✕ / 滚动条外观)由 `PaintEdgeCursor` 负责, + * 这里只画表格内部。定义在 `PlotViewPanelPaint.cpp`。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] layout 面板布局(表头、列边界、行矩形全部来自它)。 + * @param [IN] tabLane 该网格配置(横向光标阈值)。 + */ + void PaintPanelCountTableRows(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane); + + /** + * @brief 列表式面板(均值滤波 / 中心轴 / 弱峰调整 / 重采样 / 选区截取)的**行内容**。 + * + * 条目栏(三角 + 名称 + 复选框)+ 展开后的参数行;参数行形态由 `kind` 决定。 + * 定义在 `PlotViewPanelPaint.cpp`。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] layout 面板布局。 + * @param [IN] tabLane 该网格配置(勾选状态 / 参数 / 悬停与拖拽态)。 + * @param [IN] kind 面板种类。 + * @param [IN] laneIndex 网格下标。 + */ + void PaintPanelListRows(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane, + EdgePanelKind kind, std::size_t laneIndex); + + /** + * @brief 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。 + * + * 绘制顺序固定为 条目 → 表头 → 吸顶分组栏(最后画的压住向上滚动的条目)。 + * 定义在 `PlotViewPanelPaint.cpp`。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] layout 面板布局(含 `markerGroups` / `markerRows`)。 + * @param [IN] tabLane 该网格配置(透明度 / 选中行 / 悬停态)。 + * @param [IN] tabLanes 全部网格(取各列实测宽度)。 + * @param [IN] laneIndex 网格下标。 + */ + void PaintPanelMarkerGroups(gd::Graphics& g, const EdgePanelLayout& layout, const Lane& tabLane, + std::vector& tabLanes, std::size_t laneIndex); + /** * @brief 计算某个实验性功能面板的布局。 * @param [IN] laneIndex 网格下标。 diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index c1990d9..8ad0615 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -1314,6 +1314,28 @@ namespace jdi return metrics; } + /** + * @brief 绘制浮动面板**内容区滚动条**(轨道 + 滑块)。 + * + * 三个排版分支(表格 / 列表 / 自由标记)共用同一段外观:任何微调(颜色、圆角、 + * 最小滑块高度)只需改这里一处。`scrollTrack` 为空矩形时直接返回。 + * + * @param [IN] g 目标绘制上下文。 + * @param [IN] layout 面板布局(取 `scrollTrack` / `scrollThumb`)。 + * @param [IN] theme 当前主题。 + */ + inline void PaintPanelScrollBar(gd::Graphics& g, const PlotView::EdgePanelLayout& layout, + const Theme& theme) + { + if (layout.scrollTrack.Width <= 0.0f) + { + return; + } + UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, + WithAlpha(theme.border, 0x88)); + UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, theme.borderStrong); + } + /** * @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。 * diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 871411c..8edfb81 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -4784,38 +4784,6 @@ namespace jdi } } - // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- - // 「边沿计数」「边沿计数(中心轴)」「均值滤波」「弱峰调整」共用同一套样式, - // 仅标题、内容行与底部按钮不同。 - // - // 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画, - // 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms, - // 而贴图 <1ms —— 拖动弱峰调整滑块时的主要卡顿就来自这一层)。 - // 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号的文字严格垂直对齐)。 - // ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次, - // 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。 - const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, - std::size_t falling, bool complete, const gd::RectF& clip) - { - // ⚠️ **必须用 `UiTextClipped`**:GDI 文本不受 GDI+ `SetClip` 约束, - // 内容区滚动时统计文字会画到面板标题栏 / 面板之外 - //(用户 2026-11-02 报告“滚动时绘制超出了浮动面板”的直接原因)。 - if (EdgeCountsPending(rising, falling, complete)) - { - UiTextClipped(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, - TextAlign::LEFT, clip); - return; - } - const float gap = kEdgePanelStatColumnGap * m_scale; - const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f); - UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, - gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted, - TextAlign::LEFT, clip); - UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, - gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height), - m_theme->textMuted, TextAlign::LEFT, clip); - }; - std::vector nextSlots; std::vector dirtyRects; const std::uint64_t panelGlobalKey = PanelGlobalKey(); @@ -4894,28 +4862,13 @@ namespace jdi UiSetTextLayer(panelTextLayer + static_cast(panelOrdinal)); // ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`):过去宽度 / 布局 / 本函数各写一份 // 6 个 `kind == XXX` + 3 条组合判断,新增面板漏改一处就错位(见该函数注释)。 + // NOTE: layout classification comes from PanelStyleOf(kind) only. + // The three content painters below also read it, so nothing here re-derives + // "kind == XXX" combinations. 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 = panelStyle.listRows; - const bool countTable = panelStyle.countTable; + const bool freeMarker = panelStyle.markerFooter; + const bool clipPanel = panelStyle.twoButtons; 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) { @@ -5046,7 +4999,6 @@ namespace jdi PanelTextCollector panelTextBoxes(UiGetTextRectSink()); const float titleHeight = kEdgePanelTitleHeight * m_scale; - const float pad = kEdgePanelPad * m_scale; // 以下整段(面板底色 → 标题栏 → 各控件 → 行内容)的画字都要走本地收集器。 UiSetTextRectSink(&panelTextBoxes); @@ -5108,17 +5060,10 @@ namespace jdi badgeHot ? m_theme->accent : m_theme->border, badgeHot ? 1.4f : 1.0f); UiText(g, L"G" + FormatInteger(index + 1), m_fonts->monoSmall, badgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); - const std::wstring title = - meanFilter ? std::wstring(L"均值滤波") - : (weakPeak ? std::wstring(kWeakPeakTitle) - : (freeMarker ? std::wstring(L"自由标记") - : (resamplePanel - ? std::wstring(kResampleTitle) - : (clipPanel - ? std::wstring(kClipTitle) - : ((centerAxis ? L"中心轴边沿计数" - : L"边沿计数") + - EdgeRangeSuffix()))))); + // ⚠️ 标题取 `PanelTitle(kind)` 这一**唯一来源**(与 `EdgePanelWidth` 量宽用的是同一条链)。 + // 过去这里是 7 层三元式、宽度那边是**另一条**(漏了 MARK/CLIP), + // 任何文案改动都可能让宽度与标题不匹配(标题被省略 / 右侧留白)。 + const std::wstring title = PanelTitle(kind); UiText(g, title, m_fonts->uiSmall, gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y, panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight, @@ -5153,948 +5098,24 @@ namespace jdi // 「边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)**固定不滚动**, // 下面每条可见信号一行,行底色 = 该信号的配色,三列与表头严格对齐。 // 内容超高时右侧出现滚动条(只占内容区,不覆盖表头)。 - if (countTable) + if (panelStyle.countTable) { - // ---- 表头 ---- - { - 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, EdgeCountTableHeader(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); - - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) - { - if (rowIndex >= layout.rowHeaders.size() || - rowIndex >= layout.rowNameRects.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]; - if (row.Y + row.Height <= 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 要求)。 - UiFillRoundRect(g, row, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - - // 信号名(第一列):超出列宽时省略。 - 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, ink.text, TextAlign::LEFT, - layout.content); - - // 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。 - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (resolved.axis != nullptr) - { - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, - tabLane.edgeValue, 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; - } - const std::wstring text = - EdgeCountCellText((column == 1) ? rising : falling, complete); - UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(cell.left, row.Y, cell.width, row.Height), ink.text, - TextAlign::CENTER, layout.content); - } - } - - g.Restore(tableSave); - - // 内容滚动条(只在内容超高时出现;表头不参与滚动)。 - paintPanelScrollBar(layout); + PaintPanelCountTableRows(g, layout, tabLane); } // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 - if (listStyle) + if (panelStyle.listRows) { - const int meanSave = 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 auto paintMeanCheck = [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); - }; - - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) - { - const SignalRef& ref = layout.rowRefs[rowIndex]; - if (rowIndex >= layout.rowHeaders.size() || - rowIndex >= layout.rowChecks.size()) - { - break; - } - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - const gd::RectF& header = layout.rowHeaders[rowIndex]; - // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: - // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 - //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 - float rowTop = header.Y; - float rowBottom = header.Y + header.Height; - const auto extendRow = [&rowBottom](const gd::RectF& rect) { - if (rect.Width > 0.0f && rect.Height > 0.0f) - { - rowBottom = std::max(rowBottom, rect.Y + rect.Height); - } - }; - if (rowIndex < layout.rowLevelTracks.size()) - { - extendRow(layout.rowLevelTracks[rowIndex]); - } - if (rowIndex < layout.rowLevelValues.size()) - { - extendRow(layout.rowLevelValues[rowIndex]); - } - if (rowIndex < layout.rowLevelResets.size()) - { - extendRow(layout.rowLevelResets[rowIndex]); - } - if (rowIndex < layout.rowIterTracks.size()) - { - extendRow(layout.rowIterTracks[rowIndex]); - } - if (rowIndex < layout.rowIterValues.size()) - { - extendRow(layout.rowIterValues[rowIndex]); - } - if (rowIndex < layout.rowIterResets.size()) - { - extendRow(layout.rowIterResets[rowIndex]); - } - if (rowIndex < layout.rowStats.size()) - { - extendRow(layout.rowStats[rowIndex]); - } - if (rowIndex < layout.rowInfoMaxInterval.size()) - { - extendRow(layout.rowInfoMaxInterval[rowIndex]); - } - if (rowIndex < layout.rowInfoMinInterval.size()) - { - extendRow(layout.rowInfoMinInterval[rowIndex]); - } - if (rowIndex < layout.rowIntervalField.size()) - { - extendRow(layout.rowIntervalField[rowIndex]); - } - if (rowIndex < layout.rowFrequencyField.size()) - { - extendRow(layout.rowFrequencyField[rowIndex]); - } - if (rowBottom <= layout.content.Y || - rowTop >= layout.content.Y + layout.content.Height) - { - continue; // 整行(含全部参数行)完全滚出可视范围 - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const UiInk ink = UiContrastInk(signalColor); - // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 - //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 - const Lane::WeakPeakItem* peakItem = - weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; - const Lane::MeanFilterItem* item = - weakPeak ? nullptr - : (centerAxis ? tabLane.FindEdgeCenterItem(ref) - : tabLane.FindMeanFilterItem(ref)); - const Lane::ResampleItem* resampleItem = - resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; - const bool checked = clipPanel ? tabLane.ClipEnabled(ref) - : (resamplePanel ? (resampleItem != nullptr) - : (weakPeak ? (peakItem != nullptr) - : (item != nullptr))); - const bool rowCollapsed = - clipPanel ? true - : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) - : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) - : (item != nullptr && item->collapsed))); - - // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); - // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 - UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 - //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 - // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, - // 只比 (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)); - } - - // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 - const gd::RectF& triangle = layout.rowTriangles[rowIndex]; - if (triangle.Width > 0.0f && checked) - { - UiDrawTriangleIcon(g, triangle, ink.text, - rowCollapsed ? IconDirection::RIGHT - : IconDirection::DOWN); - } - - // 名称:从三角右侧到复选框左侧。 - const gd::RectF& check = layout.rowChecks[rowIndex]; - const float nameLeft = (triangle.Width > 0.0f) - ? (triangle.X + triangle.Width + 6.0f * m_scale) - : (header.X + 8.0f * m_scale); - const float nameWidth = - std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); - UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, - TextAlign::LEFT, layout.content); - paintMeanCheck(check, checked, ink, signalColor); - - if (!checked || rowCollapsed) - { - continue; - } - - // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ - // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 - if (resamplePanel) - { - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel); - const auto paintInfoRow = [&](const gd::RectF& row, const wchar_t* label, - const std::wstring& text) - { - if (row.Width <= 0.0f) - { - return; - } - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth), - row.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - // ⚠️ 统计数字**绝不省略**(用户 2026-11-03 要求): - // 面板宽度已按真实文本量宽(`EdgePanelWidth` 逐信号实测), - // 因此这里用 `TextAlign::RIGHT` + 完整矩形即可; - // 万一仍被窗口边界夹住,也宁可超出面板右缘(由内容区裁剪兜底), - // **不要**退化成 `…` 让用户看不到真实数值。 - UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y, - std::max(10.0f, row.Width - labelWidth - - 8.0f * m_scale), - row.Height), - m_theme->textStrong, TextAlign::RIGHT, - layout.content); - }; - const auto paintInputRow = [&](const gd::RectF& field, const wchar_t* label, - const std::wstring& text, bool editing) - { - if (field.Width <= 0.0f) - { - return; - } - // 标签与数值同高:标签从内容区左缘画到字段左缘。 - UiTextClipped( - g, label, m_fonts->uiSmall, - gd::RectF(layout.content.X, field.Y, - std::max(10.0f, field.X - 8.0f * m_scale - layout.content.X), - field.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - if (editing) - { - return; // 正在编辑:内容由 `PaintEditField` 画 - } - const gd::RectF box(field.X, field.Y + 2.0f * m_scale, field.Width, - std::max(10.0f, field.Height - 4.0f * m_scale)); - UiFillRoundRect(g, box, 3.0f * m_scale, m_theme->pressed); - UiDrawRoundRect(g, box, 3.0f * m_scale, m_theme->border, 1.0f); - UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(box.X + 4.0f * m_scale, box.Y, - std::max(6.0f, box.Width - 8.0f * m_scale), - box.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - }; - double maxInterval = 0.0; - double minInterval = 0.0; - const bool hasInterval = - (resampleItem->maxInterval > 0.0) && (resampleItem->minInterval > 0.0); - maxInterval = resampleItem->maxInterval; - minInterval = resampleItem->minInterval; - // 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法), - // 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。 - // - // ⚠️⚠️ 全部使用**真实值**(用户 2026-11-03 明确要求): - // * 时间间隔:`FormatDurationCompound` 会把次级单位保留 3 位小数 - // ⇒ `0.003696631s` 显示为 `3ms 696.631us`(旧实现舍入到 3 位有效数字 - // 得到 `3ms 700us`,把真实尾数抹掉了); - // * 频率:由**未舍入的真实间隔**换算 ⇒ `270.517 Hz`(旧实现用舍入后的 - // `3.70ms` 换算得到 `270.27 Hz`,与输入框不一致)。 - // 两条路径都用真实值 ⇒ 统计行与输入框天然一致。 - const auto intervalWithFrequency = [](double seconds) -> std::wstring - { - std::wstring text = FormatDurationCompound(seconds); - if (std::isfinite(seconds) && seconds > 0.0) - { - text += L"("; - text += FormatFrequencyText(1.0 / seconds); - text += L")"; - } - return text; - }; - if (rowIndex < layout.rowInfoMaxInterval.size()) - { - paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, - hasInterval ? intervalWithFrequency(maxInterval) : L"—"); - } - if (rowIndex < layout.rowInfoMinInterval.size()) - { - paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, - hasInterval ? intervalWithFrequency(minInterval) : L"—"); - } - const double interval = (resampleItem->interval > 0.0) - ? resampleItem->interval - : minInterval; - const bool editingInterval = - (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - const bool editingFrequency = - (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - if (rowIndex < layout.rowIntervalField.size()) - { - // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 - // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 - paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, - FormatIntervalText(interval), editingInterval); - } - if (rowIndex < layout.rowFrequencyField.size()) - { - // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。 - const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; - // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 - paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, - (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", - editingFrequency); - } - 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; - if (resolved.axis != nullptr) - { - 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, - const gd::RectF& resetRect, - const wchar_t* label, int current, int minimum, - int maximum, bool active) - { - const float labelLeft = track.X - 6.0f * m_scale - - UiMeasureTextWidth(m_fonts->uiSmall, label); - // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 - // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 - //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - 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, FormatInteger(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); - }; - - // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, - // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** - // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 - if (weakPeak) - { - // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 - const int peakAmount = std::max( - kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); - const int peakMax = std::max( - kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); - const int peakIter = - std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); - const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool editingMax = - (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 - const auto paintPeakSlider = [&](const gd::RectF& track, - const gd::RectF& valueRect, - const gd::RectF& resetRect, - const wchar_t* label, double ratio, - const std::wstring& valueText, bool active, - bool hideBody) - { - const float labelLeft = track.X - 6.0f * m_scale - - UiMeasureTextWidth(m_fonts->uiSmall, label); - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT, layout.content); - if (hideBody) - { - return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 - } - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, valueText, 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 double levelRatio = - (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; - const double iterRatio = - (peakMax > kWeakPeakIterationsMin) - ? (static_cast(peakIter - kWeakPeakIterationsMin) / - static_cast(peakMax - kWeakPeakIterationsMin)) - : 0.0; - if (rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f) - { - paintPeakSlider( - layout.rowLevelTracks[rowIndex], - rowIndex < layout.rowLevelValues.size() - ? layout.rowLevelValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowLevelResets.size() - ? layout.rowLevelResets[rowIndex] - : gd::RectF(), - kWeakPeakLevelLabel, levelRatio, - (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", - levelActive, false); - } - if (rowIndex < layout.rowIterTracks.size() && - layout.rowIterTracks[rowIndex].Width > 0.0f) - { - paintPeakSlider( - layout.rowIterTracks[rowIndex], - rowIndex < layout.rowIterValues.size() - ? layout.rowIterValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowIterResets.size() - ? layout.rowIterResets[rowIndex] - : gd::RectF(), - kWeakPeakIterationsSliderLabel, iterRatio, - FormatInteger(peakIter), iterActive, editingMax); - } - } - else - { - // 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。 - const int pointsFloor = kMeanFilterPointsMin; - const int pointsMax = std::max(pointsFloor, - std::min(kMeanFilterPointsMaxLimit, - item->maxPoints)); - const int points = - std::max(pointsFloor, std::min(pointsMax, item->points)); - const int iterationsMax = std::max(kMeanFilterIterationsMin, - std::min(kMeanFilterIterationsMaxLimit, - item->maxIterations)); - const int iterations = std::max( - kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); - const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && - (m_meanDragLane == static_cast(index)) && - (m_meanDragRef == ref) && - (m_meanDragKind == kind); - const bool iterationsActive = - (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && - (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && - (m_meanDragKind == kind); - if (rowIndex < layout.rowLevelTracks.size()) - { - paintMeanSlider(layout.rowLevelTracks[rowIndex], - rowIndex < layout.rowLevelValues.size() - ? layout.rowLevelValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowLevelResets.size() - ? layout.rowLevelResets[rowIndex] - : gd::RectF(), - kMeanFilterPointsSliderLabel, points, - pointsFloor, pointsMax, pointsActive); - } - if (rowIndex < layout.rowIterTracks.size()) - { - paintMeanSlider(layout.rowIterTracks[rowIndex], - rowIndex < layout.rowIterValues.size() - ? layout.rowIterValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowIterResets.size() - ? layout.rowIterResets[rowIndex] - : gd::RectF(), - kMeanFilterIterationsSliderLabel, iterations, - kMeanFilterIterationsMin, iterationsMax, iterationsActive); - } - } - - } - - g.Restore(meanSave); - - // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- - // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 - // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 - // "全部启用后没有出现滚动条"的根因)。 - paintPanelScrollBar(layout); + PaintPanelListRows(g, layout, tabLane, kind, index); } // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), // 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。 - if (freeMarker) + if (panelStyle.markerGroups) { - const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale; - bool markerAny = false; - for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems) - { - markerAny = markerAny || item.ref.IsValid(); - } - - const int contentState = g.Save(); - g.SetClip(layout.content, gd::CombineModeIntersect); - - if (!markerAny) - { - // ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点 - //(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。 - const wchar_t* emptyHint = tabLane.freeMarkerCursor - ? L"在网格内单击以固定数据点" - : L"开启「自由标记点选取」后单击网格可固定数据点"; - UiTextClipped(g, emptyHint, m_fonts->uiSmall, - gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, - groupRowHeight), - m_theme->textMuted, TextAlign::LEFT, layout.content); - } - - // ---- 分组栏 ---- - // ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的 - // 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。 - const auto paintGroupBar = [this, &g, &layout, &tabLane, index]( - const EdgePanelLayout::MarkerGroup& group) - { - const ResolvedSignal resolved = ResolveSignal(group.ref); - if (resolved.data == nullptr) - { - return; - } - const std::uint32_t signalColor = - (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; - // 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则), - // 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。 - const UiInk ink = UiContrastInk(signalColor); - // 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变): - // 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。 - UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - - // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 - const bool triangleHot = - (m_freeMarkerTriangleHoverLane == static_cast(index) && - m_freeMarkerTriangleHoverSignal == group.ref); - UiDrawTriangleIcon(g, group.triangle, - triangleHot ? m_theme->accentSoft : ink.text, - group.collapsed ? IconDirection::RIGHT - : IconDirection::DOWN); - - // 右侧:`N 标记点` + ✕(清除该信号全部固定点)。 - std::size_t markerCount = 0; - for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems) - { - if (entry.ref == group.ref) - { - ++markerCount; - } - } - const std::wstring countText = FormatInteger(markerCount) + L" 标记点"; - const float countWidth = - UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; - const float countRight = group.close.X - 5.0f * m_scale; - UiTextClipped(g, countText, m_fonts->monoSmall, - gd::RectF(countRight - countWidth, group.header.Y, countWidth, - group.header.Height), - ink.text, TextAlign::RIGHT, layout.content); - - const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale; - const float nameWidth = - std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft); - UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height), - ink.text, TextAlign::LEFT, layout.content); - - const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(index) && - m_freeMarkerPanelHoverSignal == group.ref && - m_freeMarkerPanelHoverItem == static_cast(-1)); - if (closeHot) - { - UiFillRoundRect(g, group.close, 3.0f * m_scale, - WithAlpha(m_theme->danger, 0x77)); - } - const float crossPad = 3.5f * m_scale; - const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; - UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad, - group.close.X + group.close.Width - crossPad, - group.close.Y + group.close.Height - crossPad, crossColor, - 1.5f * m_scale); - UiDrawLine(g, group.close.X + group.close.Width - crossPad, - group.close.Y + crossPad, group.close.X + crossPad, - group.close.Y + group.close.Height - crossPad, crossColor, - 1.5f * m_scale); - }; - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (!group.pinned) - { - paintGroupBar(group); - } - } - - // ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)---- - // ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`): - // 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。 - const std::vector& contents = - FreeMarkerContents(static_cast(index)); - // 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。 - // 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。 - { - float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; - const auto measure = [this, &widest](std::size_t column, const std::wstring& text) { - if (text.empty() || column >= 6) - { - return; - } - widest[column] = std::max(widest[column], - UiMeasureTextWidth(m_fonts->monoSmall, text)); - }; - // 「序号」列:取本网格所有分组里最大的序号文本宽度。 - std::size_t maxSerial = 0; - std::vector serialOrder; - for (const SignalRef& ref : tabLane.signals) - { - SortedFreeMarkerIndices(tabLane, ref, serialOrder); - maxSerial = std::max(maxSerial, serialOrder.size()); - } - if (maxSerial > 0) - { - measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial)); - } - for (const FreeMarkerContentEntry& entry : contents) - { - measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText); - measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText); - measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText); - measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText); - measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText); - } - std::array& stored = - tabLanes[index].freeMarkerColumnWidths; - for (std::size_t column = 0; column < 6; ++column) - { - // +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本 - // 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。 - const float want = - (widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f; - if (std::fabs(stored[column] - want) > 0.5f) - { - stored[column] = want; - } - } - } - // 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线, - // 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界 - //(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。 - const auto paintColumnSeparators = [this, &g, &layout](float y, float height) { - int lastVisible = -1; - for (int column = 0; column < 6; ++column) - { - if (layout.markerColumns[column].width > 0.0f) - { - lastVisible = column; - } - } - for (int column = 0; column < lastVisible; ++column) - { - const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; - if (cell.width <= 0.0f) - { - continue; - } - const float lineX = - cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f; - UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale, - WithAlpha(m_theme->borderStrong, 0x6C), 1.0f); - } - }; - for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows) - { - if (markerRow.itemIndex >= contents.size()) - { - continue; - } - const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex]; - - // ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)---- - const bool selected = - (tabLane.freeMarkerSelected == static_cast(markerRow.itemIndex)); - const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(index) && - m_freeMarkerRowHoverItem == markerRow.itemIndex); - if (selected || rowHot) - { - UiFillRect(g, - gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale, - markerRow.rect.Width, - markerRow.rect.Height - 2.0f * m_scale), - selected ? WithAlpha(m_theme->accent, 0x3C) - : WithAlpha(m_theme->accent, 0x1E)); - } - - const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { - return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, - layout.markerColumns[column].width, - markerRow.rect.Height); - }; - // ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求): - // 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。 - // ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`, - // **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上 - //(用户 2026-10-22 报告的显示错误)。 - const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, - const std::wstring& text, std::uint32_t color) { - if (layout.markerColumns[column].width <= 0.0f || text.empty()) - { - return; - } - UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, - TextAlign::LEFT, layout.content); - }; - paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial), - m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, - m_theme->textStrong); - if (layout.markerHasEnumColumn) - { - paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text); - } - paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText, - m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted); - paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText, - m_theme->textMuted); - - // 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。 - paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height); - - // 行尾 ✕:移除该固定点(与之前一致)。 - const gd::RectF& closeRect = markerRow.close; - const bool hot = (m_freeMarkerPanelHoverLane == static_cast(index) && - m_freeMarkerPanelHoverItem == markerRow.itemIndex); - UiFillRoundRect(g, closeRect, 3.0f * m_scale, - hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg); - UiDrawRoundRect(g, closeRect, 3.0f * m_scale, - hot ? m_theme->danger : m_theme->border, 1.0f); - const float crossPad = 3.5f * m_scale; - const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted; - UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, - closeRect.X + closeRect.Width - crossPad, - closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale); - UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, - closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, - crossColor, 1.2f * m_scale); - } - - // ---- 表头(每个展开分组一行,与条目共用列边界)---- - // ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。 - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f) - { - continue; - } - // 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。 - UiFillRect(g, group.tableHeader, - MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22)); - UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f, - group.tableHeader.X + group.tableHeader.Width, - group.tableHeader.Y + group.tableHeader.Height - 1.0f, - WithAlpha(m_theme->borderStrong, 0x99), 1.0f); - for (int column = 0; column < 6; ++column) - { - const EdgePanelLayout::MarkerColumn& cell = - layout.markerColumns[column]; - if (cell.width <= 0.0f) - { - continue; - } - UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall, - gd::RectF(cell.left, group.tableHeader.Y, cell.width, - group.tableHeader.Height), - m_theme->textMuted, TextAlign::LEFT, layout.content); - } - // 表头的列分隔竖线(与数据行同一套列边界)。 - paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height); - } - - // ---- 吸顶的分组栏(最后画:压住向上滚动的条目)---- - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - if (group.pinned) - { - paintGroupBar(group); - } - } - - g.Restore(contentState); - - // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- - paintPanelScrollBar(layout); - - // ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)---- - if (layout.opacityTrack.Width > 0.0f) - { - UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall, - gd::RectF(panel.X + pad, layout.opacityValue.Y, - layout.opacityTrack.X - panel.X - pad, layout.opacityValue.Height), - m_theme->textMuted, TextAlign::LEFT); - const bool sliderActive = - (m_dragMode == PlotDragMode::MARKER_OPACITY && - m_markerOpacityDragLane == static_cast(index)) || - UiPointInRect(layout.opacityTrack, static_cast(m_mouseX), - static_cast(m_mouseY)); - // 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。 - UiDrawSlider(g, layout.opacityTrack, - static_cast(tabLane.freeMarkerOpacity), m_theme->accent, - sliderActive, m_scale); - const int percent = - static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); - UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, - layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT); - } + PaintPanelMarkerGroups(g, layout, tabLane, tabLanes, index); } @@ -6551,4 +5572,4 @@ namespace jdi UiDrawLine(g, cx, cy - depth, cx + halfWidth, cy - depth * 0.15f, ink, stroke); } -} // namespace jdi \ No newline at end of file +} // namespace jdi diff --git a/Components/PlotView/Src/PlotViewPanelPaint.cpp b/Components/PlotView/Src/PlotViewPanelPaint.cpp new file mode 100644 index 0000000..1da47b2 --- /dev/null +++ b/Components/PlotView/Src/PlotViewPanelPaint.cpp @@ -0,0 +1,1016 @@ +// PlotView shard -- floating panel CONTENT painting. +// +// Extracted verbatim from PaintEdgeCursor (~1600 lines) so that each panel layout +// style (count table / list rows / marker groups) lives in its own member function +// that only receives what it actually needs. The shared frame (border, title bar, +// disclosure triangle, badge, close button) and the footer buttons stay in +// PaintEdgeCursor because they are identical for every panel. +// +// Hard constraints: +// 1. Geometry comes from EdgePanelLayout fields only -- never re-derive coordinates. +// 2. Text inside the content area must use UiTextClipped(..., layout.content): +// GDI text ignores GDI+ clipping and would otherwise paint over the title bar. +// 3. Layout classification reads pvdetail::PanelStyleOf(kind) only. + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + /** + * @brief Count-table rows of the edge-count panel (fixed header + scrollable rows). + * + * Row background = the signal colour; the three columns come from + * `layout.tableColumns`, i.e. header and data share one geometry source. + */ + void PlotView::PaintPanelCountTableRows(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane) + { + // ---- 表头 ---- + { + 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, EdgeCountTableHeader(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); + + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + if (rowIndex >= layout.rowHeaders.size() || + rowIndex >= layout.rowNameRects.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]; + if (row.Y + row.Height <= 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 要求)。 + UiFillRoundRect(g, row, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + + // 信号名(第一列):超出列宽时省略。 + 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, ink.text, TextAlign::LEFT, + layout.content); + + // 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。 + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (resolved.axis != nullptr) + { + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, + tabLane.edgeValue, 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; + } + const std::wstring text = + EdgeCountCellText((column == 1) ? rising : falling, complete); + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(cell.left, row.Y, cell.width, row.Height), ink.text, + TextAlign::CENTER, layout.content); + } + } + + g.Restore(tableSave); + + // 内容滚动条(只在内容超高时出现;表头不参与滚动)。 + PaintPanelScrollBar(g, layout, *m_theme); + } + + /** + * @brief List-style panel rows: entry bar (triangle + name + check box) plus the + * expanded parameter rows. + * + * Covers MEAN / CENTER / PEAK / RESAMPLE / CLIP. Parameter-row shape is decided by + * `kind` (RESAMPLE: four read-only/input rows, CENTER: an extra statistics row, the + * rest: two slider rows). + */ + void PlotView::PaintPanelListRows(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane, EdgePanelKind kind, + std::size_t laneIndex) + { + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool weakPeak = (kind == EdgePanelKind::PEAK); + const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); + const bool clipPanel = (kind == EdgePanelKind::CLIP); + const std::size_t index = laneIndex; + // "Rising / falling" two-column layout (fixed column origins => strictly aligned). + // NOTE: must use UiTextClipped -- GDI text ignores GDI+ SetClip. + const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, + std::size_t falling, bool complete, + const gd::RectF& clip) + { + if (EdgeCountsPending(rising, falling, complete)) + { + UiTextClipped(g, L"\u7edf\u8ba1\u4e2d\u2026", m_fonts->monoSmall, rect, + m_theme->textMuted, TextAlign::LEFT, clip); + return; + } + const float gap = kEdgePanelStatColumnGap * m_scale; + const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f); + UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, + gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), + m_theme->textMuted, TextAlign::LEFT, clip); + UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, + gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, + rect.Height), + m_theme->textMuted, TextAlign::LEFT, clip); + }; + const int meanSave = 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 auto paintMeanCheck = [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); + }; + + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (rowIndex >= layout.rowHeaders.size() || + rowIndex >= layout.rowChecks.size()) + { + break; + } + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF& header = layout.rowHeaders[rowIndex]; + // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: + // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 + //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 + float rowTop = header.Y; + float rowBottom = header.Y + header.Height; + const auto extendRow = [&rowBottom](const gd::RectF& rect) { + if (rect.Width > 0.0f && rect.Height > 0.0f) + { + rowBottom = std::max(rowBottom, rect.Y + rect.Height); + } + }; + if (rowIndex < layout.rowLevelTracks.size()) + { + extendRow(layout.rowLevelTracks[rowIndex]); + } + if (rowIndex < layout.rowLevelValues.size()) + { + extendRow(layout.rowLevelValues[rowIndex]); + } + if (rowIndex < layout.rowLevelResets.size()) + { + extendRow(layout.rowLevelResets[rowIndex]); + } + if (rowIndex < layout.rowIterTracks.size()) + { + extendRow(layout.rowIterTracks[rowIndex]); + } + if (rowIndex < layout.rowIterValues.size()) + { + extendRow(layout.rowIterValues[rowIndex]); + } + if (rowIndex < layout.rowIterResets.size()) + { + extendRow(layout.rowIterResets[rowIndex]); + } + if (rowIndex < layout.rowStats.size()) + { + extendRow(layout.rowStats[rowIndex]); + } + if (rowIndex < layout.rowInfoMaxInterval.size()) + { + extendRow(layout.rowInfoMaxInterval[rowIndex]); + } + if (rowIndex < layout.rowInfoMinInterval.size()) + { + extendRow(layout.rowInfoMinInterval[rowIndex]); + } + if (rowIndex < layout.rowIntervalField.size()) + { + extendRow(layout.rowIntervalField[rowIndex]); + } + if (rowIndex < layout.rowFrequencyField.size()) + { + extendRow(layout.rowFrequencyField[rowIndex]); + } + if (rowBottom <= layout.content.Y || + rowTop >= layout.content.Y + layout.content.Height) + { + continue; // 整行(含全部参数行)完全滚出可视范围 + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const UiInk ink = UiContrastInk(signalColor); + // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 + //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 + const Lane::WeakPeakItem* peakItem = + weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; + const Lane::MeanFilterItem* item = + weakPeak ? nullptr + : (centerAxis ? tabLane.FindEdgeCenterItem(ref) + : tabLane.FindMeanFilterItem(ref)); + const Lane::ResampleItem* resampleItem = + resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; + const bool checked = clipPanel ? tabLane.ClipEnabled(ref) + : (resamplePanel ? (resampleItem != nullptr) + : (weakPeak ? (peakItem != nullptr) + : (item != nullptr))); + const bool rowCollapsed = + clipPanel ? true + : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) + : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) + : (item != nullptr && item->collapsed))); + + // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); + // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 + UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 + //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 + // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, + // 只比 (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)); + } + + // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 + const gd::RectF& triangle = layout.rowTriangles[rowIndex]; + if (triangle.Width > 0.0f && checked) + { + UiDrawTriangleIcon(g, triangle, ink.text, + rowCollapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + } + + // 名称:从三角右侧到复选框左侧。 + const gd::RectF& check = layout.rowChecks[rowIndex]; + const float nameLeft = (triangle.Width > 0.0f) + ? (triangle.X + triangle.Width + 6.0f * m_scale) + : (header.X + 8.0f * m_scale); + const float nameWidth = + std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, + TextAlign::LEFT, layout.content); + paintMeanCheck(check, checked, ink, signalColor); + + if (!checked || rowCollapsed) + { + continue; + } + + // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ + // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 + if (resamplePanel) + { + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel); + const auto paintInfoRow = [&](const gd::RectF& row, const wchar_t* label, + const std::wstring& text) + { + if (row.Width <= 0.0f) + { + return; + } + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth), + row.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + // ⚠️ 统计数字**绝不省略**(用户 2026-11-03 要求): + // 面板宽度已按真实文本量宽(`EdgePanelWidth` 逐信号实测), + // 因此这里用 `TextAlign::RIGHT` + 完整矩形即可; + // 万一仍被窗口边界夹住,也宁可超出面板右缘(由内容区裁剪兜底), + // **不要**退化成 `…` 让用户看不到真实数值。 + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y, + std::max(10.0f, row.Width - labelWidth - + 8.0f * m_scale), + row.Height), + m_theme->textStrong, TextAlign::RIGHT, + layout.content); + }; + const auto paintInputRow = [&](const gd::RectF& field, const wchar_t* label, + const std::wstring& text, bool editing) + { + if (field.Width <= 0.0f) + { + return; + } + // 标签与数值同高:标签从内容区左缘画到字段左缘。 + UiTextClipped( + g, label, m_fonts->uiSmall, + gd::RectF(layout.content.X, field.Y, + std::max(10.0f, field.X - 8.0f * m_scale - layout.content.X), + field.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (editing) + { + return; // 正在编辑:内容由 `PaintEditField` 画 + } + const gd::RectF box(field.X, field.Y + 2.0f * m_scale, field.Width, + std::max(10.0f, field.Height - 4.0f * m_scale)); + UiFillRoundRect(g, box, 3.0f * m_scale, m_theme->pressed); + UiDrawRoundRect(g, box, 3.0f * m_scale, m_theme->border, 1.0f); + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(box.X + 4.0f * m_scale, box.Y, + std::max(6.0f, box.Width - 8.0f * m_scale), + box.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + }; + double maxInterval = 0.0; + double minInterval = 0.0; + const bool hasInterval = + (resampleItem->maxInterval > 0.0) && (resampleItem->minInterval > 0.0); + maxInterval = resampleItem->maxInterval; + minInterval = resampleItem->minInterval; + // 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法), + // 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。 + // + // ⚠️⚠️ 全部使用**真实值**(用户 2026-11-03 明确要求): + // * 时间间隔:`FormatDurationCompound` 会把次级单位保留 3 位小数 + // ⇒ `0.003696631s` 显示为 `3ms 696.631us`(旧实现舍入到 3 位有效数字 + // 得到 `3ms 700us`,把真实尾数抹掉了); + // * 频率:由**未舍入的真实间隔**换算 ⇒ `270.517 Hz`(旧实现用舍入后的 + // `3.70ms` 换算得到 `270.27 Hz`,与输入框不一致)。 + // 两条路径都用真实值 ⇒ 统计行与输入框天然一致。 + const auto intervalWithFrequency = [](double seconds) -> std::wstring + { + std::wstring text = FormatDurationCompound(seconds); + if (std::isfinite(seconds) && seconds > 0.0) + { + text += L"("; + text += FormatFrequencyText(1.0 / seconds); + text += L")"; + } + return text; + }; + if (rowIndex < layout.rowInfoMaxInterval.size()) + { + paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, + hasInterval ? intervalWithFrequency(maxInterval) : L"—"); + } + if (rowIndex < layout.rowInfoMinInterval.size()) + { + paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, + hasInterval ? intervalWithFrequency(minInterval) : L"—"); + } + const double interval = (resampleItem->interval > 0.0) + ? resampleItem->interval + : minInterval; + const bool editingInterval = + (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + const bool editingFrequency = + (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + if (rowIndex < layout.rowIntervalField.size()) + { + // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 + // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 + paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, + FormatIntervalText(interval), editingInterval); + } + if (rowIndex < layout.rowFrequencyField.size()) + { + // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。 + const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; + // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 + paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, + (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", + editingFrequency); + } + 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; + if (resolved.axis != nullptr) + { + 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, + const gd::RectF& resetRect, + const wchar_t* label, int current, int minimum, + int maximum, bool active) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 + // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 + //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + 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, FormatInteger(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); + }; + + // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, + // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** + // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 + if (weakPeak) + { + // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 + const int peakAmount = std::max( + kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); + const int peakMax = std::max( + kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); + const int peakIter = + std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); + const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == ref); + const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == ref); + const bool editingMax = + (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 + const auto paintPeakSlider = [&](const gd::RectF& track, + const gd::RectF& valueRect, + const gd::RectF& resetRect, + const wchar_t* label, double ratio, + const std::wstring& valueText, bool active, + bool hideBody) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (hideBody) + { + return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 + } + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, valueText, 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 double levelRatio = + (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; + const double iterRatio = + (peakMax > kWeakPeakIterationsMin) + ? (static_cast(peakIter - kWeakPeakIterationsMin) / + static_cast(peakMax - kWeakPeakIterationsMin)) + : 0.0; + if (rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f) + { + paintPeakSlider( + layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kWeakPeakLevelLabel, levelRatio, + (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", + levelActive, false); + } + if (rowIndex < layout.rowIterTracks.size() && + layout.rowIterTracks[rowIndex].Width > 0.0f) + { + paintPeakSlider( + layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kWeakPeakIterationsSliderLabel, iterRatio, + FormatInteger(peakIter), iterActive, editingMax); + } + } + else + { + // 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。 + const int pointsFloor = kMeanFilterPointsMin; + const int pointsMax = std::max(pointsFloor, + std::min(kMeanFilterPointsMaxLimit, + item->maxPoints)); + const int points = + std::max(pointsFloor, std::min(pointsMax, item->points)); + const int iterationsMax = std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, + item->maxIterations)); + const int iterations = std::max( + kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); + const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == ref) && + (m_meanDragKind == kind); + const bool iterationsActive = + (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && + (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && + (m_meanDragKind == kind); + if (rowIndex < layout.rowLevelTracks.size()) + { + paintMeanSlider(layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kMeanFilterPointsSliderLabel, points, + pointsFloor, pointsMax, pointsActive); + } + if (rowIndex < layout.rowIterTracks.size()) + { + paintMeanSlider(layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kMeanFilterIterationsSliderLabel, iterations, + kMeanFilterIterationsMin, iterationsMax, iterationsActive); + } + } + + } + + g.Restore(meanSave); + + // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- + // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 + // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 + // "全部启用后没有出现滚动条"的根因)。 + PaintPanelScrollBar(g, layout, *m_theme); + } + + /** + * @brief Marker panel content: per-signal group bars (collapsible) + table header + + * fixed-point rows, with sticky group bars. + * + * Paint order is rows -> header -> sticky group bar (the last one covers rows that + * scroll up). Rows hidden under a sticky bar are already excluded by the layout. + */ + void PlotView::PaintPanelMarkerGroups(gd::Graphics& g, const EdgePanelLayout& layout, + const Lane& tabLane, std::vector& tabLanes, + std::size_t laneIndex) { + const std::size_t index = laneIndex; + const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale; + bool markerAny = false; + for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems) + { + markerAny = markerAny || item.ref.IsValid(); + } + + const int contentState = g.Save(); + g.SetClip(layout.content, gd::CombineModeIntersect); + + if (!markerAny) + { + // ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点 + //(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。 + const wchar_t* emptyHint = tabLane.freeMarkerCursor + ? L"在网格内单击以固定数据点" + : L"开启「自由标记点选取」后单击网格可固定数据点"; + UiTextClipped(g, emptyHint, m_fonts->uiSmall, + gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, + groupRowHeight), + m_theme->textMuted, TextAlign::LEFT, layout.content); + } + + // ---- 分组栏 ---- + // ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的 + // 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。 + const auto paintGroupBar = [this, &g, &layout, &tabLane, index]( + const EdgePanelLayout::MarkerGroup& group) + { + const ResolvedSignal resolved = ResolveSignal(group.ref); + if (resolved.data == nullptr) + { + return; + } + const std::uint32_t signalColor = + (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; + // 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则), + // 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。 + const UiInk ink = UiContrastInk(signalColor); + // 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变): + // 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。 + UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + + // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 + const bool triangleHot = + (m_freeMarkerTriangleHoverLane == static_cast(index) && + m_freeMarkerTriangleHoverSignal == group.ref); + UiDrawTriangleIcon(g, group.triangle, + triangleHot ? m_theme->accentSoft : ink.text, + group.collapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + + // 右侧:`N 标记点` + ✕(清除该信号全部固定点)。 + std::size_t markerCount = 0; + for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems) + { + if (entry.ref == group.ref) + { + ++markerCount; + } + } + const std::wstring countText = FormatInteger(markerCount) + L" 标记点"; + const float countWidth = + UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; + const float countRight = group.close.X - 5.0f * m_scale; + UiTextClipped(g, countText, m_fonts->monoSmall, + gd::RectF(countRight - countWidth, group.header.Y, countWidth, + group.header.Height), + ink.text, TextAlign::RIGHT, layout.content); + + const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale; + const float nameWidth = + std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height), + ink.text, TextAlign::LEFT, layout.content); + + const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(index) && + m_freeMarkerPanelHoverSignal == group.ref && + m_freeMarkerPanelHoverItem == static_cast(-1)); + if (closeHot) + { + UiFillRoundRect(g, group.close, 3.0f * m_scale, + WithAlpha(m_theme->danger, 0x77)); + } + const float crossPad = 3.5f * m_scale; + const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; + UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad, + group.close.X + group.close.Width - crossPad, + group.close.Y + group.close.Height - crossPad, crossColor, + 1.5f * m_scale); + UiDrawLine(g, group.close.X + group.close.Width - crossPad, + group.close.Y + crossPad, group.close.X + crossPad, + group.close.Y + group.close.Height - crossPad, crossColor, + 1.5f * m_scale); + }; + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (!group.pinned) + { + paintGroupBar(group); + } + } + + // ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)---- + // ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`): + // 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。 + const std::vector& contents = + FreeMarkerContents(static_cast(index)); + // 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。 + // 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。 + { + float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; + const auto measure = [this, &widest](std::size_t column, const std::wstring& text) { + if (text.empty() || column >= 6) + { + return; + } + widest[column] = std::max(widest[column], + UiMeasureTextWidth(m_fonts->monoSmall, text)); + }; + // 「序号」列:取本网格所有分组里最大的序号文本宽度。 + std::size_t maxSerial = 0; + std::vector serialOrder; + for (const SignalRef& ref : tabLane.signals) + { + SortedFreeMarkerIndices(tabLane, ref, serialOrder); + maxSerial = std::max(maxSerial, serialOrder.size()); + } + if (maxSerial > 0) + { + measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial)); + } + for (const FreeMarkerContentEntry& entry : contents) + { + measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText); + measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText); + measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText); + measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText); + measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText); + } + std::array& stored = + tabLanes[index].freeMarkerColumnWidths; + for (std::size_t column = 0; column < 6; ++column) + { + // +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本 + // 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。 + const float want = + (widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f; + if (std::fabs(stored[column] - want) > 0.5f) + { + stored[column] = want; + } + } + } + // 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线, + // 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界 + //(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。 + const auto paintColumnSeparators = [this, &g, &layout](float y, float height) { + int lastVisible = -1; + for (int column = 0; column < 6; ++column) + { + if (layout.markerColumns[column].width > 0.0f) + { + lastVisible = column; + } + } + for (int column = 0; column < lastVisible; ++column) + { + const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + const float lineX = + cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f; + UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale, + WithAlpha(m_theme->borderStrong, 0x6C), 1.0f); + } + }; + for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows) + { + if (markerRow.itemIndex >= contents.size()) + { + continue; + } + const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex]; + + // ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)---- + const bool selected = + (tabLane.freeMarkerSelected == static_cast(markerRow.itemIndex)); + const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(index) && + m_freeMarkerRowHoverItem == markerRow.itemIndex); + if (selected || rowHot) + { + UiFillRect(g, + gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale, + markerRow.rect.Width, + markerRow.rect.Height - 2.0f * m_scale), + selected ? WithAlpha(m_theme->accent, 0x3C) + : WithAlpha(m_theme->accent, 0x1E)); + } + + const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { + return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, + layout.markerColumns[column].width, + markerRow.rect.Height); + }; + // ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求): + // 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。 + // ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`, + // **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上 + //(用户 2026-10-22 报告的显示错误)。 + const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, + const std::wstring& text, std::uint32_t color) { + if (layout.markerColumns[column].width <= 0.0f || text.empty()) + { + return; + } + UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, + TextAlign::LEFT, layout.content); + }; + paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial), + m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, + m_theme->textStrong); + if (layout.markerHasEnumColumn) + { + paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text); + } + paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText, + m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText, + m_theme->textMuted); + + // 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。 + paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height); + + // 行尾 ✕:移除该固定点(与之前一致)。 + const gd::RectF& closeRect = markerRow.close; + const bool hot = (m_freeMarkerPanelHoverLane == static_cast(index) && + m_freeMarkerPanelHoverItem == markerRow.itemIndex); + UiFillRoundRect(g, closeRect, 3.0f * m_scale, + hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg); + UiDrawRoundRect(g, closeRect, 3.0f * m_scale, + hot ? m_theme->danger : m_theme->border, 1.0f); + const float crossPad = 3.5f * m_scale; + const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted; + UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, + closeRect.X + closeRect.Width - crossPad, + closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale); + UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, + closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, + crossColor, 1.2f * m_scale); + } + + // ---- 表头(每个展开分组一行,与条目共用列边界)---- + // ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。 + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f) + { + continue; + } + // 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。 + UiFillRect(g, group.tableHeader, + MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22)); + UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f, + group.tableHeader.X + group.tableHeader.Width, + group.tableHeader.Y + group.tableHeader.Height - 1.0f, + WithAlpha(m_theme->borderStrong, 0x99), 1.0f); + for (int column = 0; column < 6; ++column) + { + const EdgePanelLayout::MarkerColumn& cell = + layout.markerColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall, + gd::RectF(cell.left, group.tableHeader.Y, cell.width, + group.tableHeader.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + } + // 表头的列分隔竖线(与数据行同一套列边界)。 + paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height); + } + + // ---- 吸顶的分组栏(最后画:压住向上滚动的条目)---- + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (group.pinned) + { + paintGroupBar(group); + } + } + + g.Restore(contentState); + + // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- + PaintPanelScrollBar(g, layout, *m_theme); + + // ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)---- + if (layout.opacityTrack.Width > 0.0f) + { + // 几何全部取自布局(与命中测试同源)。 + const gd::RectF& panelRect = layout.panel; + const float labelGap = kEdgePanelPad * m_scale; + UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall, + gd::RectF(panelRect.X + labelGap, layout.opacityValue.Y, + layout.opacityTrack.X - panelRect.X - labelGap, + layout.opacityValue.Height), + m_theme->textMuted, TextAlign::LEFT); + const bool sliderActive = + (m_dragMode == PlotDragMode::MARKER_OPACITY && + m_markerOpacityDragLane == static_cast(index)) || + UiPointInRect(layout.opacityTrack, static_cast(m_mouseX), + static_cast(m_mouseY)); + // 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。 + UiDrawSlider(g, layout.opacityTrack, + static_cast(tabLane.freeMarkerOpacity), m_theme->accent, + sliderActive, m_scale); + const int percent = + static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); + UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, + layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT); + } + } + +} // namespace jdi +