对应 Code_Review_Report §15.6 第 1 项(方案 B:有结构,无列视图)。
结构
- EdgePanelLayout 的 16 个平行 std::vector 合并为 std::vector<PanelRow> rows,
PanelRow 带 17 个具名成员:ref / header / nameRect / nameClose / triangle / check /
stats / levelTrack / levelValue / levelReset / iterTrack / iterValue / iterReset /
infoMaxInterval / infoMinInterval / intervalField / frequencyField。
- 唯一新增入口:AppendRow(ref, header, nameRect, triangle, check)(返回新行引用)与
AppendMarkerGroupRow(ref, nameRect, nameClose)(「自由标记」分组行)。
参数行/重采样行一律 layout.rows.back().xxx = … 赋值。
- 原 int rows(可见信号数)与新容器同名 ⇒ 改名 signalCount,并注明它与 rows.size()
在 MARK 面板里不相等(只有有固定点的信号才有行)。
消掉的两类问题
1. 某分支漏 push 一列 ⇒ 该列之后所有行读到别人的矩形(静默错位)。现在一行一次成型。
2. rowRefs 字段重载(列表式 = 行信号 / MARK = 分组信号,其余 12 列必须为空)。
现在每行自带 ref,语义由“填哪些字段”表达;不变式简化为语义断言
(每行必须有名称区;MARK 行不得带 header/check/stats/滑块/重采样字段)。
顺带清理
- 合并后 rowIndex < layout.rowXxx.size() 全部变成恒真,批量删除 50+ 处
(含 15 元组长度数组、? x : gd::RectF() 三元判空、if(...){extendRow(...)} 包裹)。
- 调试框与缓存键改为逐行遍历(新增字段只需在 PanelRow 与这两处循环各加一行)。
等价性证明(rowgeom 键名刻意保持不变,故可逐键比对)
old keys = 919, new keys = 965
仅存在于旧 = 0;已有键值变化 = 20(全部是 ".count":0 -> N)
仅存在于新 = 46(全部是新增列的 [NNN],值 0.00,0.00,0.00,0.00)
⇒ 逐行逐字段的真实几何零位移;7 个面板像素哈希(含 resample=2BFC74B3D9B3239C)逐项不变。
验证
- make release:零 warning;784,896 → 782,336 B(−2,560);make debug / make -C Tools 同样零 warning
- UnitTests:checks=260 failed=0 cases=19
- OffscreenChecks -all:7 检查 / 133 断言(上轮 127)全 PASS
- panels -mode compare:7 面板像素哈希逐项不变
- rowgeom -mode compare:965+7 键 0 差异;-data MF4-Record.MF4 时 1268+7 键 0 差异
- 读点:旧 243 处 layout.rowXxx[…] → 现 95 处 layout.rows[…](84 命中判定 / 11 绘制)
过程记录:清理恒真判空时曾用整段替换把「均值滤波」双滑块段误换成「弱峰」双滑块,
编译器报 brace 不平衡,用备份回滚后重做 —— 整段替换必须先精确读出上下文。
文档:Plan §十一、Progress 第 39 轮、Report §16、agent_mem 第 39 轮、Tools/README §4.1/§4.4
936 lines
45 KiB
C++
936 lines
45 KiB
C++
// 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;
|
||
// "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.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;
|
||
}
|
||
|
||
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
|
||
if (centerAxis && layout.rows[rowIndex].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(layout.rows[rowIndex].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 == ref);
|
||
const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) &&
|
||
(m_peakDragLane == static_cast<int>(index)) &&
|
||
(m_peakDragRef == ref);
|
||
const bool editingMax =
|
||
(m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) &&
|
||
(m_editLane == static_cast<int>(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<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);
|
||
}
|
||
}
|
||
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<int>(index)) &&
|
||
(m_meanDragRef == ref) &&
|
||
(m_meanDragKind == kind);
|
||
const bool iterationsActive =
|
||
(m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
|
||
(m_meanDragLane == static_cast<int>(index)) && (m_meanDragRef == 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);
|
||
}
|
||
|
||
}
|
||
|
||
g.Restore(meanSave);
|
||
|
||
// ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)----
|
||
// ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在
|
||
// 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告
|
||
// "全部启用后没有出现滚动条"的根因)。
|
||
PaintPanelScrollBar(g, layout, *m_theme);
|
||
}
|
||
|
||
/**
|
||
* @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);
|
||
}
|
||
|
||
|
||
/**
|
||
* @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<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);
|
||
}
|
||
|
||
// ---- 分组栏 ----
|
||
// ⚠️ 抽取成 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<int>(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<int>(index) &&
|
||
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);
|
||
};
|
||
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
|
||
{
|
||
if (!group.pinned)
|
||
{
|
||
paintGroupBar(group);
|
||
}
|
||
}
|
||
|
||
// ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)----
|
||
// ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`):
|
||
// 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。
|
||
const std::vector<FreeMarkerContentEntry>& contents =
|
||
FreeMarkerContents(static_cast<int>(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<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);
|
||
}
|
||
std::array<float, 6>& 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<int>(markerRow.itemIndex));
|
||
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(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<int>(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<int>(index)) ||
|
||
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);
|
||
}
|
||
}
|
||
|
||
} // namespace jdi
|
||
|