Files
Just-DataInspector/Components/PlotView/Src/PlotViewPanelPaint.cpp
T
DeepSeek V4.1 Flash 0c26da07d2 refactor(panels): 面板静态特征收敛为唯一登记表 + 标记面板绘制分段(§17.6 第 1/2 项)
- 新增 pvdetail::PanelKindTraits + PanelTraitsOf(唯一 switch):标题 / 排版类别 /
  滚动字段 / 面板状态 / 功能开关同表登记;新增面板只改「枚举 + 一行」,
  -Wswitch 仍在唯一登记点生效。PanelTitle 改为「前缀 + rangeSuffix 标志」,
  不再依赖 switch(标题降级为数据,不再需要函数指针)。
- PaintPanelMarkerGroups(约 340 行)拆成 6 个具名函数,主函数只保留绘制顺序
  (普通分组栏 -> 条目 -> 表头 -> 吸顶栏 -> 滚动条 -> 透明度行);纯搬移、0 字节变化。
- 顺手修正该函数体遗留的多余缩进。

验证:release/debug/tools 零 warning;UnitTests 260/19;OffscreenChecks -all 7/133;
panels -mode compare 7 面板像素哈希逐项不变;rowgeom compare 965+7 / 1268+7 键 0 差异。
产物 781,824 -> 782,336 B(+512,全部来自登记表)。
2026-11-04 17:22:06 +08:00

978 lines
47 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.rows.size(); ++rowIndex)
{
const SignalRef& ref = layout.rows[rowIndex].ref;
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
const gd::RectF& row = layout.rows[rowIndex].header;
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.rows[rowIndex].nameRect;
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;
const int meanSave = g.Save();
// 内容必须裁到内容区:滚动时行会越出可视范围。
g.SetClip(layout.content, gd::CombineModeIntersect);
// 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。
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.rows.size(); ++rowIndex)
{
const SignalRef& ref = layout.rows[rowIndex].ref;
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
const gd::RectF& header = layout.rows[rowIndex].header;
// 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏:
// 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内
//(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。
// 整行的纵向范围 = 条目栏 ∪ 全部参数行(未使用的字段恒为空矩形,直接 extent 即可;
// 旧结构里每一项都要先判断"数组可能更短",现在一行就是一个整体)。
const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex];
float rowTop = row.header.Y;
float rowBottom = row.header.Y + row.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);
}
};
extendRow(row.levelTrack);
extendRow(row.levelValue);
extendRow(row.levelReset);
extendRow(row.iterTrack);
extendRow(row.iterValue);
extendRow(row.iterReset);
extendRow(row.stats);
extendRow(row.infoMaxInterval);
extendRow(row.infoMinInterval);
extendRow(row.intervalField);
extendRow(row.frequencyField);
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<int>(index) &&
m_meanRowHoverRef == ref && m_meanRowHoverKind == kind)
{
UiFillRoundRect(g, header, 3.0f * m_scale,
WithAlpha(m_theme->accent, 0x2E));
}
// 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。
const gd::RectF& triangle = layout.rows[rowIndex].triangle;
if (triangle.Width > 0.0f && checked)
{
UiDrawTriangleIcon(g, triangle, ink.text,
rowCollapsed ? IconDirection::RIGHT
: IconDirection::DOWN);
}
// 名称:从三角右侧到复选框左侧。
const gd::RectF& check = layout.rows[rowIndex].check;
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;
}
// 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/
// 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。
// 排布细节见 `PaintPanelResampleRows`(从本循环里提取的独立函数)。
if (resamplePanel)
{
PaintPanelResampleRows(g, layout, rowIndex, index, ref, *resampleItem);
continue;
}
// 参数行(中心轴统计行 + 两行滑块)见 `PaintPanelParamRows`:
// 三/两行的滑块排版与"条目栏"没有共同点,留在本循环里只会让行循环膨胀到三百行。
PaintPanelParamRows(g, layout, row, kind,
PanelParamState{&resolved, item, peakItem, index});
}
g.Restore(meanSave);
// ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)----
// ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在
// 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告
// "全部启用后没有出现滚动条"的根因)。
PaintPanelScrollBar(g, layout, *m_theme);
}
/**
* @brief 「列表式面板」**单个信号的参数行**(中心轴统计行 + 两行滑块)。
*
* 从 `PaintPanelListRows` 里整块提取(第 40 轮):滑块排版(标签 / 轨道 / 数值 / 复位按钮,
* 含拖拽态 / 悬停态 / 编辑态)与"条目栏"没有共同点。**零逻辑改动**。
*/
void PlotView::PaintPanelParamRows(gd::Graphics& g, const EdgePanelLayout& layout,
const EdgePanelLayout::PanelRow& row, EdgePanelKind kind,
const PanelParamState& state)
{
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const std::size_t index = state.laneIndex;
const ResolvedSignal& resolved = *state.resolved;
const Lane::MeanFilterItem* item = state.meanItem;
const Lane::WeakPeakItem* peakItem = state.peakItem;
// 数值列的**裁剪宽度**:与布局里的 `metrics.valueColumn` 同值(`100000` 是最宽数值)。
const float valueColumn = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"1000"));
// 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号严格垂直对齐)。
// ⚠️ 必须用 `UiTextClipped`(而不是 `UiText`):GDI 文本不受 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"统计中…", 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);
};
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
if (centerAxis && row.stats.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(row.stats, 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<double>(current - minimum) /
static_cast<double>(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<float>(m_mouseX), static_cast<float>(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<int>(index)) &&
(m_peakDragRef == row.ref);
const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) &&
(m_peakDragLane == static_cast<int>(index)) &&
(m_peakDragRef == row.ref);
const bool editingMax = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) &&
(m_editLane == static_cast<int>(index)) &&
(m_editMeanRef == row.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<float>(m_mouseX),
static_cast<float>(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<double>(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5;
const double iterRatio =
(peakMax > kWeakPeakIterationsMin)
? (static_cast<double>(peakIter - kWeakPeakIterationsMin) /
static_cast<double>(peakMax - kWeakPeakIterationsMin))
: 0.0;
if (row.levelTrack.Width > 0.0f)
{
paintPeakSlider(row.levelTrack, row.levelValue, row.levelReset, kWeakPeakLevelLabel,
levelRatio,
(peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%",
levelActive, false);
}
if (row.iterTrack.Width > 0.0f)
{
paintPeakSlider(row.iterTrack, row.iterValue, row.iterReset,
kWeakPeakIterationsSliderLabel, iterRatio, FormatInteger(peakIter),
iterActive, editingMax);
}
return;
}
// 只可能是「均值滤波」或「中心轴」:中心轴多一行统计(上面已画),滑块参数同形。
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<int>(index)) &&
(m_meanDragRef == row.ref) && (m_meanDragKind == kind);
const bool iterationsActive = (m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
(m_meanDragLane == static_cast<int>(index)) &&
(m_meanDragRef == row.ref) && (m_meanDragKind == kind);
paintMeanSlider(row.levelTrack, row.levelValue, row.levelReset,
kMeanFilterPointsSliderLabel, points, pointsFloor, pointsMax, pointsActive);
paintMeanSlider(row.iterTrack, row.iterValue, row.iterReset,
kMeanFilterIterationsSliderLabel, iterations, kMeanFilterIterationsMin,
iterationsMax, iterationsActive);
}
/**
* @brief 「重采样」面板**单个信号的参数区**(四行)。
*
* 从 `PaintPanelListRows` 里整块提取(第 38 轮):这四行的排布(两行只读统计 +
* 两行联动输入框、标签在左数值在右)与其它面板的滑块参数行完全不同,
* 混在同一个行循环里只会让"条目栏 / 参数行 / 重采样行"三种排版互相纠缠。
* **零逻辑改动**,判据 = `rowgeom -mode compare` + `panels -mode compare` 全绿。
*/
void PlotView::PaintPanelResampleRows(gd::Graphics& g, const EdgePanelLayout& layout,
std::size_t rowIndex, std::size_t laneIndex,
const SignalRef& ref, const Lane::ResampleItem& item)
{
const std::size_t index = laneIndex;
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);
};
const bool hasInterval = (item.maxInterval > 0.0) && (item.minInterval > 0.0);
// 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法),
// 末尾追加一个括号内的「对应频率」尾缀(单位在 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;
};
// ⚠️ 四行均在本行的 `PanelRow` 里,不再需要 “数组可能更短” 的判空(旧平行数组结构的遗留)。
const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex];
paintInfoRow(row.infoMaxInterval, kResampleMaxIntervalLabel,
hasInterval ? intervalWithFrequency(item.maxInterval) : L"—");
paintInfoRow(row.infoMinInterval, kResampleMinIntervalLabel,
hasInterval ? intervalWithFrequency(item.minInterval) : L"—");
const double interval = (item.interval > 0.0) ? item.interval : item.minInterval;
const bool editingInterval = (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
const bool editingFrequency = (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
// 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。
// 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。
paintInputRow(row.intervalField, kResampleIntervalLabel, FormatIntervalText(interval),
editingInterval);
// ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。
const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0;
// 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。
paintInputRow(row.frequencyField, kResampleFrequencyLabel,
(hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", editingFrequency);
}
// ---------------------------------------------------------------------------
// 「自由标记」面板内容(第 41 轮拆分)
//
// 原实现是一个约 340 行的单函数,内含 5 段互不相干的绘制(分组栏 / 列宽测量 /
// 条目行 / 表头 / 透明度滑块)。任何一段出问题都要在 300 行里找,而"表头与吸顶分组栏
// 必须最后画"这条**唯一真实的顺序约束**被埋在一个循环中间。现在拆成 6 个具名函数,
// `PaintPanelMarkerGroups` 只负责顺序,判据 = `panels -mode compare` +
// `rowgeom -mode compare`(两者都必须零差异)。
// ---------------------------------------------------------------------------
/**
* @brief 把「自由标记」六列的实测文本宽度写回 `Lane::freeMarkerColumnWidths`。
*
* 含滚出视界的条目(列宽必须稳定,否则滚动时列会跳动);下一步的布局
* (`EdgePanelWidth` / `EdgePanelLayoutFor`)直接读这份宽度。
*/
void PlotView::MeasureMarkerColumnWidths(const Lane& tabLane,
const std::vector<FreeMarkerContentEntry>& contents,
std::array<float, 6>& stored)
{
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<std::size_t> 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);
}
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;
}
}
}
/**
* @brief 「自由标记」列分隔竖线(数据行与表头共用同一套列边界)。
*
* 在每列右边界(加上半个列间隙)画一条淡竖线,让「序号 / 标记值 / 枚举文本 / 增量 /
* 时间点 / 时间间隔」有明显的列分界(用户 2026-10-23 要求)。
* 最后一列不画(它右边紧接行尾 ✕)。
*/
void PlotView::PaintMarkerColumnSeparators(gd::Graphics& g, const EdgePanelLayout& 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);
}
}
/**
* @brief 「自由标记」一个信号分组栏(三角 + 名称 + `N 标记点` + ✕)。
*
* 底色 = 信号色;文字一律用 `UiContrastInk` 算出的自适应对比色。吸顶时这一栏要压住
* 下面滚动的条目,因此底色先铺**完全不透明**的同色、再叠 0xE8(观感不变)。
*/
void PlotView::PaintMarkerGroupBar(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane,
const EdgePanelLayout::MarkerGroup& group,
std::size_t laneIndex)
{
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);
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<int>(laneIndex) &&
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<int>(laneIndex) &&
m_freeMarkerPanelHoverSignal == group.ref &&
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-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);
}
/**
* @brief 「自由标记」一个固定点条目(六列 + 分隔线 + 行尾 ✕)。
*
* ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求):列宽已按实测文本宽度撑开。
* ⚠️ 文字必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`,**不认 GDI+ 裁剪**
* ⇒ 滚动到内容区上方的条目会把文字画到标题栏上(用户 2026-10-22 报告的显示错误)。
*/
void PlotView::PaintMarkerRow(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane,
const EdgePanelLayout::MarkerRow& markerRow,
const FreeMarkerContentEntry& entry, std::size_t laneIndex)
{
// ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)----
const bool selected = (tabLane.freeMarkerSelected == static_cast<int>(markerRow.itemIndex));
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(laneIndex) &&
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);
};
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 要求:各列之间要有明显分界)。
PaintMarkerColumnSeparators(g, layout, markerRow.rect.Y, markerRow.rect.Height);
// 行尾 ✕:移除该固定点(与之前一致)。
const gd::RectF& closeRect = markerRow.close;
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(laneIndex) &&
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);
}
/**
* @brief 「自由标记」一个展开分组的表头行(不透明底衬 + 六列标题)。
*
* 不透明底衬是必须的:表头画在条目**之后**,用来盖住向上滚动的条目。
*/
void PlotView::PaintMarkerTableHeader(gd::Graphics& g, const EdgePanelLayout& layout,
const EdgePanelLayout::MarkerGroup& group)
{
if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f)
{
return;
}
// 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。
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);
}
// 表头的列分隔竖线(与数据行同一套列边界)。
PaintMarkerColumnSeparators(g, layout, group.tableHeader.Y, group.tableHeader.Height);
}
/**
* @brief 「自由标记」底部「标记透明度」滑块行。
*
* 这一行**总是显示**(与「自由标记点选取」是否开启无关),几何全部取自布局
* (与命中测试同源);外观与信号样式区的「透明度」滑块完全相同(同一 `UiDrawSlider`)。
*/
void PlotView::PaintMarkerOpacityRow(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane, std::size_t laneIndex)
{
if (layout.opacityTrack.Width <= 0.0f)
{
return;
}
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<int>(laneIndex)) ||
UiPointInRect(layout.opacityTrack, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
// 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。
UiDrawSlider(g, layout.opacityTrack, static_cast<double>(tabLane.freeMarkerOpacity),
m_theme->accent, sliderActive, m_scale);
const int percent = static_cast<int>(tabLane.freeMarkerOpacity * 100.0f + 0.5f);
UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall, layout.opacityValue,
m_theme->textStrong, TextAlign::RIGHT);
}
/**
* @brief 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。
*
* 绘制顺序(硬约束):
* 普通分组栏 → 条目 → 表头 → 吸顶分组栏 → 滚动条 → 透明度滑块
* 后两段必须压在条目之上,否则向上滚动的条目会盖住表头/吸顶栏。
*/
void PlotView::PaintPanelMarkerGroups(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane, std::vector<Lane>& 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);
}
// 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`):
// 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。
const std::vector<FreeMarkerContentEntry>& contents =
FreeMarkerContents(static_cast<int>(index));
// 六列的实测宽度存回 `Lane`:下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)要用。
MeasureMarkerColumnWidths(tabLane, contents, tabLanes[index].freeMarkerColumnWidths);
// ---- 普通分组栏(吸顶的那些留到最后画)----
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (!group.pinned)
{
PaintMarkerGroupBar(g, layout, tabLane, group, index);
}
}
// ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)----
for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows)
{
if (markerRow.itemIndex >= contents.size())
{
continue;
}
PaintMarkerRow(g, layout, tabLane, markerRow, contents[markerRow.itemIndex], index);
}
// ---- 表头(每个展开分组一行,与条目共用列边界)----
// ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
PaintMarkerTableHeader(g, layout, group);
}
// ---- 吸顶的分组栏(最后画:压住向上滚动的条目)----
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (group.pinned)
{
PaintMarkerGroupBar(g, layout, tabLane, group, index);
}
}
g.Restore(contentState);
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
PaintPanelScrollBar(g, layout, *m_theme);
// ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)----
PaintMarkerOpacityRow(g, layout, tabLane, index);
}
} // namespace jdi