Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanel.cpp
T
DeepSeek-V4.1-Flash bdd703b88b 主题自适应配色 + 预览曲线连续配色 + 样式区复位按钮 + 均值滤波面板重做 + 面板缓存环带残留 + 调试层序 + 自由标记选取与性能
① 主题自适应(`Theme.cpp`):新增 `SrgbLuminance` / `ContrastRatio` / `EnsureContrast` /
   `MixToLuminance` / `ThemeLaneBackground`;四套主题的结构色(gridWeak 1.30 / gridStrong 1.65 /
   axisLine 2.10 / borderStrong 1.70)按底色自动校正,Solarised Light 逐位不变;网格叠底改用
   `ThemeLaneBackground()`(4 处),深色主题下网格层与绘图区底色可辨。
② 派生预览曲线(`HighContrastDerived`)重写为**连续**方案:方向沿信号所在侧远离底色,
   目标亮度 = max(与底色对比度 3.2, 信号亮度 ± 0.18),再按亮度平滑混色;调用点传入真实网格底色
   (旧实现默认写死 Solarised Light 底色 ⇒ 深色主题永远选到更暗的那一侧、几乎看不见)。
③ 信号样式区「线宽 / 透明度」两行右侧新增**复位按钮**(`UiPaintResetIcon` 成为唯一实现,
   菜单/弱峰面板/样式区共用同一套笔画);默认线宽统一到 `DefaultSignalLineWidth(finiteCount)`
   (单点信号取 10.0)。按钮右边界与内容列对齐,内容列过窄时整列不显示。
④ 「均值滤波」面板重做:信号色叠底标题栏 + 复选框 + 展开三角 + 「均值点数 / 迭代次数」两行滑块
   (点击数值可改滑块上限、右侧复位)+ 内容区滚动;新增 `MeanFilterItem::{maxPoints,maxIterations,
   collapsed}` 与 `Lane::meanFilterScroll`,缓存键同步补全。
⑤ 浮动面板命中像素缓存时只贴**面板圆角形状之内**的像素(`CreateRoundRectRgn` 裁剪)并按本帧重画投影
   ⇒ 中键移视界后面板周围不再出现一圈旧波形。
⑥ 显示布局边界:每个浮动面板**独占一个绘制层**(按 `m_panelOrder` 递增),绘制侧
   `UiSetTextLayer` 在同一循环内同步推进 ⇒ 下层面板的边框/文字不再画到上层面板上。
⑦ 自由标记:采样候选距离改为**二维欧氏距离**(单点信号不再在整幅宽度内吸住光标),
   纵向光标画在鼠标位置、圆点落在真实采样点(新增 `FreeMarkerProbe::pointX`);
   `MinorCellSparse` 只对胜出信号算一次;折线缓存容量改为 `max(64, 信号数×2)` 且查找改为
   单键哈希 ⇒ MF4 的 285 信号合并网格中 `ProbeFreeMarker` 平均 0.11ms。

验证:`.preflight` 探针重建(`panelcheck` 71/71、`markercheck` 13/13、`ringcheck` 6/6,合计 90 条,
LTO 目标文件上复跑同样全绿);`make release` / `make debug` 零警告;产物 815,104 B。
文档:`agent_mem.md` 第十六轮(含补丁脚本与编码/行尾踩坑)。
2026-10-30 03:10:51 +08:00

1434 lines
51 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.
// SignalPanel 实现分片 —— 核心:状态、行集合构建、布局与几何、分组/信号可见性。
//
// 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
// 六个分片按**职责**划分:
// SignalPanel.cpp 核心:状态、行集合、布局与几何、可见性(**本文件**)
// SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条
// SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状
// SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度
// SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号"
// SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执
#include "SignalPanelInternal.hpp"
#include "UiBox.hpp"
#include "UiLayout.hpp"
namespace jdi
{
SignalPanel::SignalPanel()
{
m_colorRects.fill(gd::RectF());
m_modeRects.fill(gd::RectF());
m_adjustRects.fill(gd::RectF());
}
void SignalPanel::Setup(HWND host, const UiFonts* fonts, const Theme* theme, PopupMenu* menu,
ColorPickerPopup* colorPicker)
{
m_host = host;
m_fonts = fonts;
m_theme = theme;
m_menu = menu;
m_colorPicker = colorPicker;
}
void SignalPanel::SetContext(DataSet* dataSet, TabConfig* tab)
{
m_dataSet = dataSet;
m_dataSetWritable = dataSet;
m_tab = tab;
m_rowsDirty = true;
m_scrollOffset = 0.0f;
m_draggingOpacity = false;
m_draggingWidth = false;
m_edit.End();
m_editRow = -1;
m_editField = StyleEditField::NONE;
m_pendingLaneMenu = false;
m_pendingColorPicker = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
m_pendingFilterClear = false;
// 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与位移缓存一律作废。
m_groupAnims.clear();
m_rowShift.clear();
m_scrollAnimActive = false;
m_pendingTargetLaneMenu = false;
m_plotLaneRequestFlag = false;
m_pendingPlotLane = -1;
}
void SignalPanel::SetScale(float scale)
{
m_scale = (scale > 0.1f) ? scale : 1.0f;
}
bool SignalPanel::HitTest(int x, int y) const
{
return UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y));
}
void SignalPanel::BuildBoxes(UiBoxTable& boxes)
{
UiComponent::BuildBoxes(boxes);
if (m_fonts == nullptr || !UiRectHasArea(m_area))
{
return; // 尚未完成布局:内部矩形全是空的,登记出来只会误导。
}
// 行几何依赖行集合 ⇒ 先确保它是最新的(与绘制走同一个入口)。
EnsureRows();
// ── 层级 1:面板内部的四个功能分区 ────────────────────────────────
boxes.Add(m_headerRect, 1);
boxes.Add(m_filterRect, 1);
boxes.Add(m_listRect, 1);
boxes.Add(m_styleRect, 1);
// ── 层级 2:分区内部的固定控件 ────────────────────────────────────
boxes.Add(m_filterRect, 2); // 输入框外框(与分区边框区分)
boxes.Add(FilterFieldRect(), 3);
boxes.Add(FilterClearRect(), 3);
boxes.Add(m_toggleAllRect, 2);
boxes.Add(m_scrollBarRect, 2);
boxes.Add(ScrollThumbRect(), 3);
// ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ──────────────────
for (std::size_t index = 0; index < m_styleRowRects.size(); ++index)
{
boxes.Add(m_styleRowRects[index], 2);
}
for (const gd::RectF& swatch : m_colorRects)
{
boxes.Add(swatch, 3);
}
boxes.Add(m_customChipRect, 3);
for (const gd::RectF& mode : m_modeRects)
{
boxes.Add(mode, 3);
}
for (const gd::RectF& adjust : m_adjustRects)
{
boxes.Add(adjust, 3);
}
boxes.Add(m_widthTrackRect, 3);
boxes.Add(m_widthValueRect, 3);
boxes.Add(m_widthResetRect, 3);
boxes.Add(m_opacityTrackRect, 3);
boxes.Add(m_opacityValueRect, 3);
boxes.Add(m_opacityResetRect, 3);
// ── 「目标」行(样式区顶部,展示当前选中信号) ────────────────────
boxes.Add(m_targetRowRect, 2);
boxes.Add(m_targetNameRect, 3);
boxes.Add(m_targetBadgeRect, 3);
// ── 列表里**当前可见**的行,以及每行内部的元素 ────────────────────
// 被滚出列表的行不登记,否则边界会糊到列表区之外。
const float listTop = m_listRect.Y;
const float listBottom = m_listRect.Y + m_listRect.Height;
const int rowCount = static_cast<int>(m_rows.size());
for (int row = 0; row < rowCount; ++row)
{
const gd::RectF rect = RowRect(row);
if (!UiRectHasArea(rect))
{
continue;
}
if (rect.Y + rect.Height <= listTop || rect.Y >= listBottom)
{
continue;
}
boxes.Add(rect, 2);
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const bool isGroup = (item.kind == RowKind::GROUP);
// ⚠️ 行内子块的几何必须与 `RowHitPart` / `PaintRow` **完全同源**:
// 这里复用同一批常量与同一个 `RowRect`,不另写一套推算。
const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale);
const float centerY = rect.Y + rect.Height * 0.5f;
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
centerY - kCheckSize * m_scale * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
boxes.Add(disclosureRect, 3);
boxes.Add(checkRect, 3);
if (isGroup)
{
const float labelLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
boxes.Add(gd::RectF(labelLeft, rect.Y, GroupLabelWidth(row), rect.Height), 3);
boxes.Add(GroupStatsRect(row), 3);
boxes.Add(GroupCloseRect(row), 3);
continue;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale,
centerY - kSwatchSize * m_scale * 0.5f,
kSwatchSize * m_scale, kSwatchSize * m_scale);
boxes.Add(swatchRect, 3);
boxes.Add(RowNameRect(row), 3);
if (SignalBadgeVisible(ref))
{
boxes.Add(SignalBadgeRect(row), 3);
}
boxes.Add(SignalRemoveRect(row), 3);
}
// ── 内联编辑框(正在重命名时才有意义) ───────────────────────────
if (m_editField != StyleEditField::NONE)
{
boxes.Add(EditFieldRect(), 4);
}
}
int SignalPanel::TotalSignalCount() const
{
if (m_dataSet == nullptr)
{
return 0;
}
return static_cast<int>(m_dataSet->SignalCount());
}
float SignalPanel::DesiredStyleHeight() const
{
// ⚠️ 「目标」行也要计入:它是样式区最上方的一行(用户 2026-10-15 要求),
// 与其余五行同高、同间距,少算一行会导致列表与样式区重叠。
return kStylePadV * 2.0f + (kStyleRowCount + 1.0f) * kStyleRowHeight +
kStyleRowCount * kStyleRowGap;
}
bool SignalPanel::TakeLayoutDirty()
{
const bool dirty = m_layoutDirty;
m_layoutDirty = false;
return dirty;
}
void SignalPanel::ApplyFilterText(const std::wstring& text)
{
if (m_filterText == text)
{
return;
}
m_filterText = text;
// 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画全部作废
//(行数与分组可见性都会变,继续沿用会算出错误的行集合)。
m_filterCollapsed.clear();
m_groupAnims.clear();
m_rowShift.clear();
// 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。
m_rowsDirty = true;
m_scrollOffset = 0.0f;
m_layoutDirty = true;
}
bool SignalPanel::FilterMatchesSignal(const SignalData& signal) const
{
if (m_filterText.empty())
{
return true;
}
// 以 `*` 为通配符、忽略大小写,匹配"信号名中是否出现过符合该模式的片段"。
// 两侧补 `*` 使模式等价于子串匹配,因此下面这些写法都能按直觉工作:
// `编码器` → 名称包含"编码器"
// `编码器*相` → 名称中出现以"编码器"开头、"相"结尾的片段
// `*x10` / `ABS*` → 尾部/首部片段匹配(无需用户自己补另一侧的 `*`)
std::wstring pattern = L"*";
pattern += m_filterText;
pattern += L'*';
return WildcardMatchNoCase(signal.name, pattern);
}
bool SignalPanel::IsGroupCollapsed(int groupIndex) const
{
if (groupIndex < 0)
{
return false;
}
// 筛选模式使用**独立**的折起状态:筛选只是临时子集视图,不应污染普通列表的布局选择;
// 且筛选结果默认展开(便于直接看到匹配项)。
if (!m_filterText.empty())
{
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
{
return false;
}
return m_filterCollapsed[static_cast<std::size_t>(groupIndex)];
}
return (m_tab != nullptr) && m_tab->IsCollapsed(groupIndex);
}
void SignalPanel::SetGroupCollapsed(int groupIndex, bool collapsed)
{
if (groupIndex < 0)
{
return;
}
if (!m_filterText.empty())
{
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
{
m_filterCollapsed.resize(static_cast<std::size_t>(groupIndex) + 1, false);
}
m_filterCollapsed[static_cast<std::size_t>(groupIndex)] = collapsed;
return;
}
if (m_tab != nullptr && m_tab->IsCollapsed(groupIndex) != collapsed)
{
m_tab->ToggleCollapsed(groupIndex);
}
}
int SignalPanel::ShownSignalCount(int groupIndex) const
{
if (m_dataSet == nullptr || groupIndex < 0)
{
return 0;
}
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return 0;
}
if (m_filterText.empty())
{
return static_cast<int>(group->signals.size());
}
int count = 0;
for (const SignalData& signal : group->signals)
{
if (FilterMatchesSignal(signal))
{
++count;
}
}
return count;
}
int SignalPanel::FindGroupAnimation(int groupIndex) const
{
for (std::size_t index = 0; index < m_groupAnims.size(); ++index)
{
if (m_groupAnims[index].groupIndex == groupIndex)
{
return static_cast<int>(index);
}
}
return -1;
}
int SignalPanel::GroupRowIndex(int groupIndex) const
{
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
if (m_rows[index].kind == RowKind::GROUP && m_rows[index].groupIndex == groupIndex)
{
return static_cast<int>(index);
}
}
return -1;
}
float SignalPanel::NaturalContentHeight() const
{
float total = 0.0f;
for (const ListRow& row : m_rows)
{
total += row.height;
}
return total;
}
float SignalPanel::ShownBlockHeight(int groupIndex) const
{
return static_cast<float>(ShownSignalCount(groupIndex)) * kSignalRowHeight * m_scale;
}
float SignalPanel::AnimEasedProgress(const GroupAnim& anim) const
{
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
float progress = (anim.durationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(anim.durationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:起步快、接近完成时明显减速(“快速展开 / 收起后慢慢减速”)。
return 1.0f - std::pow(1.0f - progress, 3.0f);
}
void SignalPanel::UpdateAnimGeometry()
{
m_rowShift.assign(m_rows.size(), 0.0f);
// 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。
// 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。
m_rowTop.assign(m_rows.size() + 1, 0.0f);
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height;
}
// 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。
// 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。
m_rowTop.assign(m_rows.size() + 1, 0.0f);
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height;
}
float totalShift = 0.0f;
for (const GroupAnim& anim : m_groupAnims)
{
const float progress = AnimEasedProgress(anim);
// 收起:从起始位移推到“全部藏进组名栏”(= blockHeight);
// 展开:从起始位移推回 0(逐条从组名栏下方冒出)。
// 起始位移在“中途反向点击”时就是当时的位移 ⇒ 不会跳变。
const float target = anim.collapsing ? anim.blockHeight : 0.0f;
const float shift = anim.fromShift + (target - anim.fromShift) * progress;
if (anim.scrollSynced)
{
// 与滚动同步推进:让悬浮组名始终留在列表最上方
//(滚动偏移与平移量按同一进度推进,二者的合成让组名的屏幕位置恒定)。
m_scrollOffset = anim.scrollFrom + (anim.scrollTo - anim.scrollFrom) * progress;
}
const int headerRow = GroupRowIndex(anim.groupIndex);
if (headerRow < 0 || shift <= 0.0f)
{
continue;
}
// ⚠️ 平移只作用于**组名行之后**的行:组名自己是“卷尺的外壳”,始终不动,
// 信号行从它下面滑过去并被裁掉。
for (int row = headerRow + 1; row < static_cast<int>(m_rowShift.size()); ++row)
{
m_rowShift[static_cast<std::size_t>(row)] += shift;
}
totalShift += shift;
}
m_contentHeight = std::max(0.0f, NaturalContentHeight() - totalShift);
}
bool SignalPanel::StartGroupToggle(int groupIndex, bool collapse, float scrollFrom, float scrollTo,
bool syncScroll)
{
if (groupIndex < 0 || m_tab == nullptr)
{
return false;
}
// 同一分组只保留一个动画条目:再次切换时移除旧的,并从**当前位移**续接
//(用户可能在折起没播完时就点展开,此时不能跳回干净的一端)。
float fromShift = 0.0f;
const int existing = FindGroupAnimation(groupIndex);
if (existing >= 0)
{
const GroupAnim& previous = m_groupAnims[static_cast<std::size_t>(existing)];
const float previousProgress = AnimEasedProgress(previous);
const float previousTarget = previous.collapsing ? previous.blockHeight : 0.0f;
fromShift = previous.fromShift + (previousTarget - previous.fromShift) * previousProgress;
m_groupAnims.erase(m_groupAnims.begin() + existing);
}
const float blockHeight = ShownBlockHeight(groupIndex);
if (blockHeight <= 0.5f)
{
// 没有信号行可动画(空分组):直接把状态定下来,并清掉可能的残留位移。
UpdateAnimGeometry();
m_rowsDirty = true;
return false;
}
if (existing < 0)
{
// 没有正在播放的动画 ⇒ 从“干净的一端”出发(展开 = 全部藏在组名栏内)。
fromShift = collapse ? 0.0f : blockHeight;
}
GroupAnim anim;
anim.groupIndex = groupIndex;
anim.collapsing = collapse;
anim.rowCount = ShownSignalCount(groupIndex);
anim.blockHeight = blockHeight;
anim.fromShift = ClampFloat(fromShift, 0.0f, blockHeight);
anim.scrollFrom = scrollFrom;
anim.scrollTo = scrollTo;
anim.scrollSynced = syncScroll && (std::fabs(scrollTo - scrollFrom) > 0.5f);
anim.startMs = GetTickCount64();
// 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。
const float durationMs = std::max(
kGroupAnimMinMs,
std::min(kGroupAnimMaxMs,
kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast<float>(anim.rowCount)));
anim.durationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
if (anim.scrollSynced)
{
// 立即接管滚动偏移(后续每帧由 `UpdateAnimGeometry` 推进)。
m_scrollOffset = scrollFrom;
}
m_groupAnims.push_back(anim);
// 折起时该组的信号行必须**留在行集合里**(它们正在“滑进组名栏”),
// 因此需要按“有动画”的规则重建一次行集合。
m_rowsDirty = true;
UpdateAnimGeometry();
return true;
}
float SignalPanel::AnimationHideTopForRow(int row, int stickyRow) const
{
if (m_groupAnims.empty() || row < 0 || row >= static_cast<int>(m_rows.size()))
{
return -1.0e9f;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind != RowKind::SIGNAL || FindGroupAnimation(item.groupIndex) < 0)
{
return -1.0e9f;
}
const int headerRow = GroupRowIndex(item.groupIndex);
if (headerRow < 0)
{
return -1.0e9f;
}
// 组名的**绘制**下边界:悬浮时用悬浮位置,否则用自然位置。
// 信号行越过这条线即被裁掉 ⇒ 形成“滑进组名栏”的观感。
const float headerTop = (stickyRow == headerRow)
? StickyGroupRowTop(headerRow, static_cast<int>(m_rows.size()))
: RowRect(headerRow).Y;
return headerTop + m_rows[static_cast<std::size_t>(headerRow)].height;
}
void SignalPanel::BuildRows()
{
m_rows.clear();
m_rowsDirty = false;
// 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。
// 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。
InvalidateRowCache();
if (m_dataSet == nullptr || m_tab == nullptr)
{
m_contentHeight = 0.0f;
m_rowShift.clear();
return;
}
const float groupHeight = kGroupRowHeight * m_scale;
const float signalHeight = kSignalRowHeight * m_scale;
const bool filtering = !m_filterText.empty();
const std::vector<SignalGroup>& files = m_dataSet->Groups();
for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex)
{
const int gi = static_cast<int>(groupIndex);
// 参与列出的信号:筛选模式只保留匹配项;整组都没有匹配则**不显示该组**。
std::vector<int> shown;
const std::size_t signalCount = files[groupIndex].signals.size();
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
{
if (!filtering || FilterMatchesSignal(files[groupIndex].signals[signalIndex]))
{
shown.push_back(static_cast<int>(signalIndex));
}
}
if (filtering && shown.empty())
{
continue;
}
const bool collapsed = IsGroupCollapsed(gi);
const bool animating = (FindGroupAnimation(gi) >= 0);
ListRow groupRow;
groupRow.kind = RowKind::GROUP;
groupRow.groupIndex = gi;
groupRow.height = groupHeight;
// 「N 项」按当前模式下实际列出的信号数显示:筛选时 = 该组内符合筛选条件的个数。
groupRow.displayCount = static_cast<int>(shown.size());
m_rows.push_back(groupRow);
// 折起且没有动画 ⇒ 该组的信号行完全不占高度(不出现在行集合里)。
// ⚠️ 有动画时必须**保留全部信号行**(含正在折起的):它们整组向上平移,
// 逐条滑进组名栏并被裁掉 —— 行高不变,因此动画期间无需重建行集合。
if (collapsed && !animating)
{
continue;
}
for (int ordinal = 0; ordinal < static_cast<int>(shown.size()); ++ordinal)
{
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.groupIndex = gi;
signalRow.signalIndex = shown[static_cast<std::size_t>(ordinal)];
signalRow.height = signalHeight;
signalRow.ordinal = ordinal;
m_rows.push_back(signalRow);
}
}
// 内容高度与逐行位移由动画状态推出(无动画时即“自然高度”)。
UpdateAnimGeometry();
}
void SignalPanel::EnsureRows()
{
if (m_rowsDirty)
{
BuildRows();
RefreshScrollBar();
return;
}
if (m_rowShift.size() != m_rows.size())
{
// 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。
UpdateAnimGeometry();
}
}
void SignalPanel::RefreshScrollBar(bool clampScroll)
{
if (m_listFullWidth <= 0.0f)
{
m_scrollBarVisible = false;
return;
}
m_scrollBarVisible = (m_contentHeight > m_listRect.Height + 0.5f);
if (m_scrollBarVisible)
{
const float thickness = kScrollBarThickness * m_scale;
m_listRect.Width = std::max(0.0f, m_listFullWidth - thickness - 2.0f * m_scale);
m_scrollBarRect = gd::RectF(m_area.X + m_area.Width - thickness - 3.0f * m_scale, m_listRect.Y,
thickness, m_listRect.Height);
}
else
{
m_listRect.Width = m_listFullWidth;
m_scrollBarRect = gd::RectF();
}
if (clampScroll)
{
ClampScroll();
}
}
void SignalPanel::ClampScroll()
{
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
m_scrollOffset = std::max(0.0f, std::min(m_scrollOffset, maxOffset));
}
gd::RectF SignalPanel::RowRect(int row) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return gd::RectF();
}
// 「组名悬浮」期间把分组行钉在列表顶部绘制(见 `PaintStickyGroupRow`):
// 本函数是行内所有派生的唯一几何来源,因此只需在这里覆盖顶部坐标,
// 名称框 / ✕ / 徽标等位置会自动跟随。
//
// ⚠️ 行位图缓存渲染时整体平移到"位图坐标系"(见 `m_rowRenderActive` 的说明:
// GDI 文本不认 GDI+ 变换,必须在几何层平移)。
const float renderOffsetX = m_rowRenderActive ? -m_rowRenderOriginX : 0.0f;
const float renderOffsetY = m_rowRenderActive ? -m_rowRenderOriginY : 0.0f;
if (m_rowYOverrideActive)
{
return gd::RectF(m_listRect.X + renderOffsetX, m_rowYOverrideY + renderOffsetY,
m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height);
}
float top = m_listRect.Y - m_scrollOffset;
// O(1) 查表:前缀和由 `UpdateAnimGeometry()` 维护。
// ⚠️ 前缀和与行集合长度不一致只可能出现在"行集合刚变、几何还没更新"的极短窗口,
// 此时退回逐行累加以保证结果仍然正确(不追求速度)。
const std::size_t rowIndex = static_cast<std::size_t>(row);
if (m_rowTop.size() == m_rows.size() + 1)
{
top += m_rowTop[rowIndex];
}
else
{
for (std::size_t index = 0; index < rowIndex; ++index)
{
top += m_rows[index].height;
}
}
// 折起/展开动画的刚性平移:组名行之后的行整组上移。
if (rowIndex < m_rowShift.size())
{
top -= m_rowShift[rowIndex];
}
return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width,
m_rows[rowIndex].height);
}
int SignalPanel::GroupLastRowIndex(int row, int rowCount) const
{
// 列表行按分组连续排列 ⇒ 本组的最后一行就是下一个分组行之前的那一行。
int last = row;
for (int next = row + 1; next < rowCount; ++next)
{
if (m_rows[static_cast<std::size_t>(next)].kind == RowKind::GROUP)
{
break;
}
last = next;
}
return last;
}
int SignalPanel::StickyGroupRowIndex(int rowCount) const
{
// 「组名悬浮」:分组行被滚出列表上边界、而该组内仍有行留在列表里时,
// 把分组行**钉在列表顶部**,直到该组全部行都滚过去为止。
// 用户要求:组名要一直可见,方便在长列表(例如上百个信号的 MF4)里辨认当前分组。
if (m_rowYOverrideActive || m_listRect.Height <= 0.0f)
{
return -1;
}
for (int row = 0; row < rowCount; ++row)
{
if (m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
{
continue;
}
if (RowRect(row).Y >= m_listRect.Y)
{
// 分组行本身仍在列表内(或更靠下):无需悬浮。
continue;
}
const int last = GroupLastRowIndex(row, rowCount);
if (RowRect(last).Y + m_rows[static_cast<std::size_t>(last)].height <= m_listRect.Y)
{
// 整组(含最后一行)已全部滚过去:组名跟随一起向上,不再出现。
continue;
}
return row;
}
return -1;
}
float SignalPanel::StickyGroupRowTop(int row, int rowCount) const
{
// 悬浮位置(用户 2026-10-15 要求“平滑过渡”,不要突然消失):
// * 平时钉在列表上边界(`m_listRect.Y`);
// * 当**本组最后一行的下边界**上移越过悬浮组名的下边界时,组名跟着一起上移
// ⇒ 随滚动条自然滚出列表,而不是跳到位置后突然消失。
// 该公式同时保证「悬浮组名的下边界永远不超过本组最后一行的下边界」,
// 而后者就是**下一个分组行的自然位置** —— 因此紧挨着的下一个组名不可能被覆盖
//(旧实现固定钉在 `m_listRect.Y`,会把下一个组名盖住,让它的信号看起来属于上一组)。
const int last = GroupLastRowIndex(row, rowCount);
const float lastBottom =
RowRect(last).Y + m_rows[static_cast<std::size_t>(last)].height;
return std::min(m_listRect.Y, lastBottom - m_rows[static_cast<std::size_t>(row)].height);
}
gd::RectF SignalPanel::GroupCloseRect(int row) const
{
const gd::RectF rect = RowRect(row);
const float size = kCloseButtonSize * m_scale;
return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size,
rect.Y + (rect.Height - size) * 0.5f, size, size);
}
gd::RectF SignalPanel::SignalBadgeRect(int row) const
{
const gd::RectF rect = RowRect(row);
const float width = kBadgeWidth * m_scale;
const float height = kBadgeHeight * m_scale;
// 行尾现在被「移除该信号」✕ 占用(与分组 ✕ 同尺寸、同列),网格徽标因此整体前移;
// 两者都恒在列表最右侧,且始终保留固定的间隔,不会互相挤压。
const float right = SignalRemoveRect(row).X - kRowTailGap * m_scale;
return gd::RectF(right - width, rect.Y + (rect.Height - height) * 0.5f, width, height);
}
bool SignalPanel::SignalBadgeVisible(const SignalRef& ref) const
{
// 「已分配到网格」与「当前标签页可见」**同时**成立时徽标才存在(才绘制、才可点)。
return (m_tab != nullptr) && m_tab->IsVisible(ref) && (m_tab->LaneOfSignal(ref) >= 0);
}
gd::RectF SignalPanel::SignalRemoveRect(int row) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::SIGNAL)
{
return gd::RectF();
}
// 与分组行的关闭 ✕ **完全一致**(同尺寸、同右边距、同行内垂直居中),
// 只是作用对象是本行信号:位于信号条目的最右端。
const gd::RectF rect = RowRect(row);
const float size = kCloseButtonSize * m_scale;
return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size,
rect.Y + (rect.Height - size) * 0.5f, size, size);
}
SignalPanel::RowPart SignalPanel::RowHitPart(int row, float fx, float fy) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return RowPart::NONE;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const gd::RectF rect = RowRect(row);
const bool isGroup = (item.kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale);
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
if (isGroup)
{
if (UiPointInRect(disclosureRect, fx, fy))
{
return RowPart::DISCLOSURE;
}
if (UiPointInRect(checkRect, fx, fy))
{
return RowPart::CHECK;
}
if (UiPointInRect(GroupCloseRect(row), fx, fy))
{
return RowPart::CLOSE;
}
// 「组X」标签:与折叠三角一样是“折起该分组”的热区
//(用户 2026-10-16 要求:只有点三角或「组X」才折起,点到组名本体不折起)。
const float labelLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
const gd::RectF labelRect(labelLeft, rect.Y, GroupLabelWidth(row), rect.Height);
if (UiPointInRect(labelRect, fx, fy))
{
return RowPart::GROUP_TITLE;
}
// 「N 项」计数:**只有折叠/展开语义**(用户 2026-10-17 要求快速点击计数不得进入改名)。
// ⚠️ 必须在名称区域之前判定:计数就紧贴在名称右侧、两者同属一行。
if (UiPointInRect(GroupStatsRect(row), fx, fy))
{
return RowPart::GROUP_COUNT;
}
return RowPart::BODY;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
const bool badgeVisible = SignalBadgeVisible(ref);
// ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
if (UiPointInRect(SignalRemoveRect(row), fx, fy))
{
return RowPart::REMOVE_SIGNAL;
}
if (UiPointInRect(checkRect, fx, fy))
{
return RowPart::CHECK;
}
if (badgeVisible && UiPointInRect(SignalBadgeRect(row), fx, fy))
{
return RowPart::LANE_BADGE;
}
return RowPart::BODY;
}
float SignalPanel::ContentOffsetBeforeRow(int row) const
{
// 与 `RowRect` 共用同一份前缀和(O(1)),不再逐行累加。
const int count = std::min(row, static_cast<int>(m_rows.size()));
if (count <= 0)
{
return 0.0f;
}
if (m_rowTop.size() == m_rows.size() + 1)
{
return m_rowTop[static_cast<std::size_t>(count)];
}
float top = 0.0f;
for (int index = 0; index < count; ++index)
{
top += m_rows[static_cast<std::size_t>(index)].height;
}
return top;
}
void SignalPanel::LayoutStyleControls()
{
const float padH = kStylePadH * m_scale;
const float labelWidth = kStyleLabelWidth * m_scale;
const float labelGap = kStyleLabelGap * m_scale;
const float rowHeight = kStyleRowHeight * m_scale;
const float rowStep = (kStyleRowHeight + kStyleRowGap) * m_scale;
const float firstTop = m_styleRect.Y + kStylePadV * m_scale;
// 内容列宽度带上下限:面板变窄时收缩,面板变宽时不再延伸,五行始终共用同一宽度。
const float available = std::max(40.0f, m_styleRect.Width - padH * 2.0f - labelWidth - labelGap);
const float contentWidth = std::min(available, kStyleContentMax * m_scale);
// 标签列 + 内容列整体靠左对齐(始终从左侧内边距开始),不随面板变宽而居中偏移。
const float groupWidth = labelWidth + labelGap + contentWidth;
const float groupLeft = m_styleRect.X + padH;
const float contentLeft = groupLeft + labelWidth + labelGap;
// ---- 顶部「目标」行:显示当前正在被编辑的目标信号(名称 + GX 标签)----
// 用户 2026-10-15 要求:位于样式自定义区最上方(预设颜色色块之上),
// 显示效果与交互效果与信号选择区域的信号行相同(悬停高亮、双击改名、单键滚动联动)。
m_targetRowRect = gd::RectF(groupLeft, firstTop, groupWidth, kStyleTargetRowHeight * m_scale);
for (int index = 0; index < static_cast<int>(kStyleRowCount); ++index)
{
m_styleRowRects[static_cast<std::size_t>(index)] =
gd::RectF(groupLeft, firstTop + static_cast<float>(index + 1) * rowStep, groupWidth,
rowHeight);
}
const gd::RectF& colorRow = m_styleRowRects[0];
const gd::RectF& modeRow = m_styleRowRects[1];
const gd::RectF& adjustRow = m_styleRowRects[2];
const gd::RectF& widthRow = m_styleRowRects[3];
const gd::RectF& opacityRow = m_styleRowRects[4];
// ---- 颜色行:预设色块 + 同尺寸自定义颜色入口(合计恰好铺满内容列宽度)----
int paletteCount = 0;
SignalPalette(paletteCount);
const float swatchGap = kSwatchGap * m_scale;
const float swatchMaxGap = kSwatchMaxGap * m_scale;
const float swatchMin = kSwatchMin * m_scale;
int count = std::min(paletteCount, static_cast<int>(m_colorRects.size()));
float swatchSize = kSwatchEdge * m_scale;
float gap = swatchGap;
while (count >= 1)
{
const float slots = static_cast<float>(count + 1);
const float fit = (contentWidth - swatchGap * (slots - 1.0f)) / slots;
if (count == 1 || fit >= swatchMin)
{
swatchSize = std::max(swatchMin, std::min(kSwatchEdge * m_scale, fit));
// 用实际色块尺寸反算间距,让颜色行与透明度、线宽、样式各行同宽。
gap = (contentWidth - swatchSize * slots) / static_cast<float>(count);
gap = std::max(swatchGap, std::min(gap, swatchMaxGap));
break;
}
--count;
}
m_colorCount = std::max(1, count);
// 颜色行与其余各行共用同一左边界(不再居中),保证五行纵向对齐。
const float colorRowLeft = contentLeft;
const float swatchTop = colorRow.Y + (rowHeight - swatchSize) * 0.5f;
for (int index = 0; index < m_colorCount; ++index)
{
m_colorRects[static_cast<std::size_t>(index)] =
gd::RectF(colorRowLeft + static_cast<float>(index) * (swatchSize + gap), swatchTop,
swatchSize, swatchSize);
}
m_customChipRect =
gd::RectF(colorRowLeft + static_cast<float>(m_colorCount) * (swatchSize + gap), swatchTop,
swatchSize, swatchSize);
// ---- 样式行:三个等宽按钮铺满内容列 ----
const float modeGap = 6.0f * m_scale;
const float modeWidth =
std::max(20.0f, (contentWidth - modeGap * static_cast<float>(kModeOptionCount - 1)) /
static_cast<float>(kModeOptionCount));
for (int index = 0; index < kModeOptionCount; ++index)
{
m_modeRects[static_cast<std::size_t>(index)] =
gd::RectF(contentLeft + static_cast<float>(index) * (modeWidth + modeGap), modeRow.Y,
modeWidth, rowHeight);
}
// ---- 「调整」行:系数/偏移 · 镜像 · 绝对值(三个铺满内容列的等宽按钮)----
const float adjustGap = kAdjustGap * m_scale;
const float adjustWidth =
std::max(20.0f, (contentWidth - adjustGap * static_cast<float>(kAdjustCount - 1)) /
static_cast<float>(kAdjustCount));
for (int index = 0; index < kAdjustCount; ++index)
{
m_adjustRects[static_cast<std::size_t>(index)] =
gd::RectF(contentLeft + static_cast<float>(index) * (adjustWidth + adjustGap), adjustRow.Y,
adjustWidth, rowHeight);
}
// ---- 线宽行与透明度行:几何完全一致,保证纵向宽度对齐(线宽在上、透明度在下)----
// 两行右端各有一个**复位按钮**(用户 2026-10-29 要求):
// 按钮贴齐内容列右缘,数值文本与轨道在它左侧依次收缩 ⇒ 不会超出内容列。
// 内容列太窄(放不下"轨道 + 数值 + 按钮")时整列不显示复位按钮,避免把滑块挤没。
const float resetSize = (contentWidth >= kSliderResetSize * 3.0f * m_scale)
? std::min(kSliderResetSize * m_scale, rowHeight)
: 0.0f;
const float resetGap = (resetSize > 0.0f) ? kSliderResetGap * m_scale : 0.0f;
const float sliderWidth = contentWidth - (resetSize > 0.0f ? (resetSize + resetGap) : 0.0f);
const float valueWidth = std::min(kSliderValueWidth * m_scale, sliderWidth * 0.4f);
const float trackWidth = std::max(24.0f, sliderWidth - valueWidth - kSliderValueGap * m_scale);
const float trackHeight = kTrackHeight * m_scale;
const float valueLeft = contentLeft + trackWidth + kSliderValueGap * m_scale;
const float resetLeft = contentLeft + contentWidth - resetSize;
m_widthTrackRect =
gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight);
m_widthValueRect = gd::RectF(valueLeft, widthRow.Y, valueWidth, rowHeight);
m_widthResetRect =
(resetSize > 0.0f)
? gd::RectF(resetLeft, widthRow.Y + (rowHeight - resetSize) * 0.5f, resetSize, resetSize)
: gd::RectF();
m_opacityTrackRect = gd::RectF(contentLeft, opacityRow.Y + (rowHeight - trackHeight) * 0.5f,
trackWidth, trackHeight);
m_opacityValueRect = gd::RectF(valueLeft, opacityRow.Y, valueWidth, rowHeight);
m_opacityResetRect =
(resetSize > 0.0f)
? gd::RectF(resetLeft, opacityRow.Y + (rowHeight - resetSize) * 0.5f, resetSize, resetSize)
: gd::RectF();
// 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化),
// 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。
m_targetGeomValid = false;
}
int SignalPanel::TargetLaneIndex() const
{
if (m_tab == nullptr || !SelectedRef().IsValid())
{
return -1;
}
// 未勾选绘制的信号不在任何网格的信号列表里,`LaneOfSignal` 自然返回 -1。
return m_tab->LaneOfSignal(SelectedRef());
}
bool SignalPanel::ModeOptionEnabled(int optionIndex) const
{
if (optionIndex < 0 || optionIndex >= kModeOptionCount)
{
return false;
}
// 单点信号固定用「散点」(见函数说明):其余两种绘制方式置灰且不可点。
if (m_dataSet == nullptr || !SelectedRef().IsValid())
{
return true;
}
const SignalData* signal = m_dataSet->Resolve(SelectedRef());
if (signal == nullptr || signal->finiteCount != 1)
{
return true;
}
return kModeOptions[optionIndex] == DrawMode::SCATTER;
}
void SignalPanel::RequestPlotLaneScroll(const SignalRef& ref)
{
if (m_tab == nullptr || !ref.IsValid())
{
return;
}
// 未勾选绘制到网格时 `LaneOfSignal` 返回 -1 ⇒ 不产生请求(没什么好滚的)。
const int lane = m_tab->LaneOfSignal(ref);
if (lane < 0)
{
return;
}
m_pendingPlotLane = lane;
m_plotLaneRequestFlag = true;
}
void SignalPanel::UpdateTargetRowGeometry()
{
if (m_fonts == nullptr || m_dataSet == nullptr)
{
m_targetNameRect = gd::RectF();
m_targetBadgeRect = gd::RectF();
return;
}
const SignalRef& target = SelectedRef();
const SignalData* signal = target.IsValid() ? m_dataSet->Resolve(target) : nullptr;
const std::wstring title = (signal != nullptr) ? signal->name : std::wstring();
const int lane = TargetLaneIndex();
// 缓存键:目标信号 + 它所在网格 + 名称(名称宽度与 GX 标签是否出现都由它们决定)。
if (m_targetGeomValid && m_targetGeomRef == target && m_targetGeomLane == lane &&
m_targetGeomTitle == title)
{
return;
}
m_targetGeomRef = target;
m_targetGeomLane = lane;
m_targetGeomTitle = title;
m_targetGeomValid = true;
m_targetNameRect = gd::RectF();
m_targetBadgeRect = gd::RectF();
if (signal == nullptr || m_targetRowRect.Width <= 0.0f)
{
return;
}
const float labelWidth = kStyleLabelWidth * m_scale;
const float labelGap = kStyleLabelGap * m_scale;
const float contentLeft = m_targetRowRect.X + labelWidth + labelGap;
const float contentWidth = std::max(0.0f, m_targetRowRect.Width - labelWidth - labelGap);
const float badgeWidth = kBadgeWidth * m_scale;
const float badgeHeight = kBadgeHeight * m_scale;
const float badgeGap = kTargetBadgeGap * m_scale;
// 只有**已绘制到网格**的信号才有 GX 标签(未勾选绘制时没有网格归属)。
const bool badgeShown = (lane >= 0) && (badgeWidth + badgeGap < contentWidth);
// 布局(用户 2026-10-16 要求“信号名与 GX 标签同时出现时左右宽度与其它控件对齐”):
// * 名称从**内容列左边缘**开始(与样式区其余行的控件同一左边界);
// * GX 标签**右对齐到内容列右边缘**(与滑轨/数值框等的右边界对齐),
// 而不是紧跟在名称后面(旧的“紧接名称”会让右边缘参差不齐);
// * 名称的可用宽度 = 到 GX 标签左侧(无标签时 = 整个内容列,因此名称可以更宽)。
const float badgeLeft = contentLeft + contentWidth - badgeWidth;
const float nameRight = badgeShown ? (badgeLeft - badgeGap) : (contentLeft + contentWidth);
const float nameWidth = std::max(0.0f, nameRight - contentLeft);
// 名称的可交互矩形:整行高,便于悬停(文本较短时也容易命中)。
m_targetNameRect = gd::RectF(contentLeft, m_targetRowRect.Y, nameWidth, m_targetRowRect.Height);
if (badgeShown)
{
m_targetBadgeRect = gd::RectF(badgeLeft,
m_targetRowRect.Y + (m_targetRowRect.Height - badgeHeight) * 0.5f,
badgeWidth, badgeHeight);
}
}
void SignalPanel::Layout(const gd::RectF& area)
{
m_area = area;
m_headerRect = gd::RectF(area.X, area.Y, area.Width, kHeaderHeight * m_scale);
// 标题下方的「筛选信号」输入框(列表内容过滤)。
const float filterHeight = kFilterHeight * m_scale;
m_filterRect = gd::RectF(area.X + kPadding * m_scale,
m_headerRect.Y + m_headerRect.Height + kFilterPadV * m_scale,
std::max(20.0f, area.Width - kPadding * 2.0f * m_scale), filterHeight);
const float styleHeight = DesiredStyleHeight() * m_scale;
m_styleRect = gd::RectF(area.X, area.Y + area.Height - styleHeight, area.Width, styleHeight);
const float listTop = m_filterRect.Y + m_filterRect.Height + kFilterPadV * m_scale;
m_listRect = gd::RectF(area.X, listTop, area.Width,
std::max(0.0f, m_styleRect.Y - listTop));
// 标题区右侧的“全部显示/隐藏”按钮。
const float toggleWidth = 68.0f * m_scale;
const float toggleHeight = 22.0f * m_scale;
m_toggleAllRect = gd::RectF(area.X + area.Width - kPadding * m_scale - toggleWidth,
m_headerRect.Y + (m_headerRect.Height - toggleHeight) * 0.5f,
toggleWidth, toggleHeight);
// 依据内容决定是否显示滚动条,并据此收缩列表可用宽度。
m_listFullWidth = m_listRect.Width;
EnsureRows();
RefreshScrollBar();
LayoutStyleControls();
}
gd::RectF SignalPanel::ScrollThumbRect() const
{
if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height || m_listRect.Height <= 0.0f)
{
return gd::RectF();
}
const float ratio = std::min(1.0f, m_listRect.Height / m_contentHeight);
const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio);
const float maxScroll = m_contentHeight - m_listRect.Height;
// 进度必须夹到 [0,1]:任何路径漏掉一次 `ClampScroll()` 都会把滑块画到轨道之外。
const float progress =
(maxScroll > 0.0f) ? std::max(0.0f, std::min(1.0f, m_scrollOffset / maxScroll)) : 0.0f;
const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight);
return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight);
}
bool SignalPanel::SignalScrollTarget(const SignalRef& ref, float& desired) const
{
desired = m_scrollOffset;
if (!ref.IsValid() || m_rows.empty() || m_listRect.Height <= 0.0f)
{
return false;
}
float top = 0.0f;
float rowTop = -1.0f;
float rowHeight = 0.0f;
int rowIndex = -1;
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
const ListRow& row = m_rows[index];
if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex &&
row.signalIndex == ref.signalIndex)
{
rowTop = top;
rowHeight = row.height;
rowIndex = static_cast<int>(index);
break;
}
top += row.height;
}
if (rowTop < 0.0f)
{
return false;
}
// ⚠️ **顶部避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"):
// 上行方向必须让目标行落在悬浮组名的下方。目前悬浮的组名只可能是目标信号**自己那个**
// 分组:分组行在其信号行之上、各分组连续排列,若目标行完整可见,则更靠下的分组行
// 不可能同时位于列表顶部(参见 `StickyGroupRowTop` 的公式)。
float topInset = 0.0f;
for (int index = rowIndex; index >= 0; --index)
{
if (m_rows[static_cast<std::size_t>(index)].kind == RowKind::GROUP)
{
topInset = m_rows[static_cast<std::size_t>(index)].height;
break;
}
}
const float visibleTop = m_scrollOffset + topInset;
const float visibleBottom = m_scrollOffset + m_listRect.Height;
if (rowTop < visibleTop)
{
desired = rowTop - topInset; // 向上滚动,直到它完整出现在组名下方
}
else if (rowTop + rowHeight > visibleBottom)
{
desired = rowTop + rowHeight - m_listRect.Height; // 向下滚动,直到完整出现
}
else
{
desired = m_scrollOffset; // 已经完整可见:不动
}
desired = std::max(0.0f, std::min(desired, std::max(0.0f, m_contentHeight - m_listRect.Height)));
return true;
}
bool SignalPanel::AreAllSignalsVisible() const
{
if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty())
{
return false;
}
return m_tab->VisibleCount() == TotalSignalCount();
}
gd::RectF SignalPanel::RowNameRect(int row) const
{
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
{
return gd::RectF();
}
const gd::RectF rect = RowRect(row);
const bool isGroup = (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale);
const float checkRight = baseX + kDisclosureWidth * m_scale + kCheckSize * m_scale;
float left = 0.0f;
float right = 0.0f;
if (isGroup)
{
// 分组名需要跳过左侧的“组X”标签与分隔线,并且**右侧让出“N 项”计数**:
// 双击进入编辑时,输入框不得把组内信号个数包进去(用户 2026-10-16 报告)。
left = checkRight + 8.0f * m_scale + GroupLabelWidth(row) + kGroupSeparatorGap * 2.0f * m_scale +
kGroupSeparatorWidth * m_scale;
const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale;
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, GroupStatsText(row));
right = statsRight - statsWidth - 6.0f * m_scale;
}
else
{
left = checkRight + 6.0f * m_scale + kSwatchSize * m_scale + 6.0f * m_scale;
// 行尾的可用右界:有网格徽标时让位给徽标,否则可直接延伸到行尾 ✕ 之前。
const SignalRef ref{m_rows[static_cast<std::size_t>(row)].groupIndex,
m_rows[static_cast<std::size_t>(row)].signalIndex};
const bool badgeVisible = SignalBadgeVisible(ref);
right = (badgeVisible ? SignalBadgeRect(row).X : SignalRemoveRect(row).X) - 6.0f * m_scale;
}
// 与 `PaintGroupRow` / `PaintSignalRow` 使用**同一最小宽度**(16 逻辑像素起步):
// 旧实现在这里因 `right <= left` 返回空矩形,而绘制侧用 `max(16, …)` 仍会画名字
// ⇒ 名称看得见、双击得到的输入框却是 0 宽(组名很长 + “N 项”很宽时可复现)。
const float width = std::max(16.0f * m_scale, right - left);
return gd::RectF(left, rect.Y + 3.0f * m_scale, width, rect.Height - 6.0f * m_scale);
}
gd::RectF SignalPanel::GroupStatsRect(int row) const
{
if (m_fonts == nullptr || m_fonts->uiSmall == nullptr || row < 0 ||
row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
{
return gd::RectF();
}
const gd::RectF rect = RowRect(row);
const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale;
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, GroupStatsText(row));
if (!(statsWidth > 0.0f))
{
return gd::RectF();
}
// 命中矩形在文本左右各多给 3 逻辑像素(计数文字很短,多给一点更好点)。
// 左侧不会吃掉名称区域:`RowNameRect` 的右边界已经让出了 `6*scale`。
const gd::RectF textRect(statsRight - statsWidth, rect.Y, statsWidth, rect.Height);
const float pad = 3.0f * m_scale;
return gd::RectF(textRect.X - pad, textRect.Y, textRect.Width + pad * 2.0f, textRect.Height);
}
float SignalPanel::GroupLabelWidth(int row) const
{
if (m_fonts == nullptr || m_fonts->uiBold == nullptr || row < 0 ||
row >= static_cast<int>(m_rows.size()))
{
return 0.0f;
}
return UiMeasureTextWidth(m_fonts->uiBold,
GroupLabelText(m_rows[static_cast<std::size_t>(row)].groupIndex));
}
std::wstring SignalPanel::GroupLabelText(int groupIndex)
{
return L"组" + std::to_wstring(groupIndex + 1);
}
std::wstring SignalPanel::GroupStatsText(int row) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return std::wstring();
}
// 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数
//(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。
return std::to_wstring(m_rows[static_cast<std::size_t>(row)].displayCount) + L" 项";
}
gd::RectF SignalPanel::FilterFieldRect() const
{
// 输入框内容区:左右与上下使用**同一内缩**,避免编辑态出现"左右空隙明显更大"的观感;
// 占位提示与显示文本也用同一内缩,因此进入/退出编辑时文字不会跳动。
const float inset = kFilterTextPad * m_scale;
return gd::RectF(m_filterRect.X + inset, m_filterRect.Y + inset,
std::max(8.0f, m_filterRect.Width - inset * 2.0f),
std::max(8.0f, m_filterRect.Height - inset * 2.0f));
}
gd::RectF SignalPanel::FilterClearRect() const
{
if (m_filterText.empty())
{
return gd::RectF();
}
const float size = std::max(12.0f, m_filterRect.Height - kFilterTextPad * 2.0f * m_scale);
const float right = m_filterRect.X + m_filterRect.Width - kFilterTextPad * m_scale;
return gd::RectF(right - size, m_filterRect.Y + (m_filterRect.Height - size) * 0.5f, size, size);
}
gd::RectF SignalPanel::EditFieldRect()
{
switch (m_editField)
{
case StyleEditField::WIDTH:
return m_widthValueRect;
case StyleEditField::OPACITY:
return m_opacityValueRect;
case StyleEditField::SIGNAL_NAME:
case StyleEditField::GROUP_TITLE:
// `row < 0` = 样式区「目标」行:输入框只覆盖**信号名**那一块,
// 不得把名称后面的 GX 标签也包进去(用户 2026-10-16 报告)。
if (m_editRow < 0)
{
UpdateTargetRowGeometry();
return m_targetNameRect;
}
return RowNameRect(m_editRow);
case StyleEditField::NONE:
default:
return gd::RectF();
}
}
HFONT SignalPanel::EditFieldFont() const
{
if (m_fonts == nullptr)
{
return nullptr;
}
// 分组名的编辑框在行内是用 `uiSmall` 绘制的(分组行字号更小),其余输入框用 `ui`。
// 绘制、命中、插入符换算三处必须一致,否则鼠标落点与插入符位置会错开。
return (m_editField == StyleEditField::GROUP_TITLE) ? m_fonts->uiSmall : m_fonts->ui;
}
gd::RectF SignalPanel::NameEditAbsorbRect(const gd::RectF& field) const
{
if (field.Width <= 0.0f || m_editRow < 0 || m_editRow >= static_cast<int>(m_rows.size()))
{
return field;
}
// 与被编辑行同高:输入框上下各留 3 逻辑像素的行内边距,那一段点下去也必须算文本操作。
const gd::RectF row = RowRect(m_editRow);
if (row.Height <= 0.0f)
{
return field;
}
return gd::RectF(field.X, row.Y, field.Width, row.Height);
}
const SignalStyle* SignalPanel::StyleOf(const SignalRef& ref) const
{
return (m_dataSet != nullptr) ? m_dataSet->Style(ref) : nullptr;
}
SignalStyle* SignalPanel::StyleOf(const SignalRef& ref)
{
return (m_dataSetWritable != nullptr) ? m_dataSetWritable->Style(ref) : nullptr;
}
const SignalRef& SignalPanel::SelectedRef() const
{
static const SignalRef fallback;
return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback;
}
bool SignalPanel::OpenColorPickerPopup(const SignalStyle& style)
{
if (m_colorPicker == nullptr)
{
return false;
}
const gd::RectF bounds = PanelBounds();
// 以记忆的自定义颜色作为初始取值,实现“再次打开即恢复上次取色”。
m_colorPicker->Open(bounds, m_customChipRect, style.customColor);
return true;
}
} // namespace jdi