Files
Just-DataInspector/Components/PlotView/Src/PlotViewPanelPaint.cpp
T
DeepSeek V4.1 Flash a9f1563896 refactor(panels): 几何算式收敛为单一来源 + 参数行提取(§17.6 第 1/2 项)
第 1 项:宽度算式与行高累加各自合并成一份来源
- 新增 `PanelColumnMetrics` + `MeasurePanelColumns(lane, kind)`:全仓唯一的一次"逐信号量宽"。
  * `EdgePanelWidth(laneIndex, kind)` 退化为"量一次 + 算一次"(`EdgePanelWidthFor`);
  * `EdgePanelLayoutFor` 量一次后,宽度与列几何(表格列 / 滑块轨道)都读同一份度量。
  * 改前两者各写一份同名同形的 UiMeasureTextWidth 链,注释只写着"同一组算式 ⇒ 不会漂移",
    那是靠人守的约定,改一处漏一处就是"面板变宽而列不动"(文字压到轨道上)。
- 新增 `PanelRowPlan` + `PlanListPanelRows(lane, kind)`:行高累加与行几何的唯一来源。
  * 改前 `EdgePanelLayoutFor` 对同一批信号遍历两趟(先算总高、再出行几何),
    两处判定(可见/勾选/收起/统计行/参数行数)必须逐字一致 —— 这类"两趟必须同步修改"
    正是"改一次布局就出错"的另一半机制。
  * 现在一个 {ref, checked, collapsed, hasStats, sliderRows} 数组同时喂高度算式与几何生成。
- 合并前先做等价性判别:`valueColumn` 两处算法不同但结果相同(100000 最宽)⇒ 可合并;
  `sliderLabel` 与 `resampleLabel` 不可合并(重采样标签更长,并进会让均值/弱峰变宽)。

第 2 项:`PaintPanelListRows` 的通用参数行提取
- 新增 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})`:中心轴统计行 +
  两条滑块分支(均值/弱峰),`paintCounts`/`paintMeanSlider`/`paintPeakSlider` 三个 lambda
  与 `valueColumn` 随之迁入。
- 逐行状态打包进 `PanelParamState`(解析结果 / 两种条目指针 / 网格下标)⇒ 参数表 5 个,
  而不是 9 个散参数(签名比函数体更难读)。
- `PaintPanelListRows` 由约 300 行降到约 120 行,只负责"条目栏 + 按 kind 分派"。
- 顺手把机械搬移遗留的"整个函数体多缩进 2 个 tab"修正。

过程记录(两次锚点误伤,已写入文档避免重犯)
- 锚点 `// 两行滑块` 在 `EdgePanelWidthFor` 内也匹配 ⇒ 误删宽度函数尾部;
- 锚点 `float rowY = ...` 在表格分支先出现 ⇒ 误删「边沿计数」的行发射。
两次都是编译器立刻报错,从 Temp 备份回滚后重做。规则:整段替换的锚点必须唯一,
同名语句多次出现时先定位所属区块再取偏移。

第 3 项(体积)按用户指示暂停投入。

验证(双基线均为"零差异")
- make release / make debug / make -C Tools:零 warning;产物 782,336 → 781,824 B(−512)
- UnitTests:checks=260 failed=0 cases=19
- OffscreenChecks -all:7 检查 / 133 断言全 PASS
- panels -mode compare:7 面板像素哈希逐项不变(含 resample=2BFC74B3D9B3239C)
- rowgeom -mode compare:965+7 键 0 差异;-data MF4-Record.MF4 时 1268+7 键 0 差异

文档:Plan §十二、Progress 第 40 轮、Report §17、agent_mem 第 40 轮
2026-11-04 16:36:47 +08:00

940 lines
45 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);
}
/**
* @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