// 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