Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanel.cpp
T
DeepSeek V4.1 Flash 97b361dedd fix(ui): 采样统计显示一致化、GX 拖拽在布局边界下可用、信号「移动到…」与拖拽排序
一、采样间隔/频率显示不一致(用户报告 1ms vs 1000us、100us 0ns)
* FormatDurationCompound 改为「先量化到整数皮秒、再整数分解」,
  彻底消除同一数值因浮点噪声落到不同单位/留下零尾数的问题;
  秒级分支与亚秒分支共用 totalPs 方案,次级进位/单位升级集中在一处。
* FormatFrequency 的单位选择改为按「舍入后的显示值」判定(999.9999999Hz
  与 1000Hz 均为 1.000kHz),并做向下校正避免 0.001kHz。

二、选区截取面板
* 提示文本改为用户指定原文「启用双光标模式以进行选区截取」。
* EdgePanelWidth 计入该提示的实际文本宽度;绘制改用 UiTextClipped 不再省略。
* viewHeight 补回漏减的 bottomPad,修掉条目区与按钮之间的空白。

三、未展开的信号条目栏悬停不再变成手型光标。

四、开启「显示布局边界」后 GX 标签可正常拖拽排序
* 抽出 PaintLaneReorderOverlay(吸附槽位 + 被拖轮廓),贴图与整幅重绘共用。
* 整幅重绘在 LANE_REORDER 时按 LaneDragOffset 逐格平移重画(被拖网格最后画),
  修掉调试模式下不跟随鼠标、无吸附提示、只能盲拖的问题。

五、信号右键新增「移动到…」二级菜单(位于「仅显示该信号」之上)
* 条目为「分组 N(组名)」,当前组打勾;无标题行;底部「新建分组」。
* MenuItem::submenuMaxRows + PopupMenu 独立子菜单滚动(滚动条、滚轮、视口命中过滤),
  IsScrollable 计入子菜单可滚状态。
* 数据层 DataSet::MoveSignalToGroup/MoveSignal 让时间轴随信号搬运
  (独占则移动、共享则复制);TabConfig::RemapAfterSignalMove 改写引用而非先删后插。
* Application::MoveSignalCore 统一入口,保证派生计算暂停、引用修正、选中迁移与同步一致。

六、信号列表支持按住拖拽排序(组内重排 / 跨组移动)
* 4px 阈值进入拖拽;条目脱离原位跟随鼠标(阴影 + 强调色描边 + 插入指示线)。
* 其它信号行/分组行按插入位置做避让位移动画(RowRect 统一叠加插值位移,
  宿主 kScrollTimerId 驱动 AdvanceSignalDragAnimation)。
* 数据层 DataSet::MoveSignal 支持任意位置;TabConfig::RemapAfterSignalReorder 线性重映射。
* 拖拽中禁用组名悬浮;双击改名与 InvalidateRows 均正确终止拖拽态。

验证:删 .o 全量重编译零 error / 零 warning;产物 873,984 B(< 1MB 预警阈值)。
本轮未使用鼠标/键盘/屏幕,未运行任何 GUI 探针,仅编译验证 + 源码级核对。
2026-11-03 19:27:43 +08:00

1529 lines
55 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);
// ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ──────────────────
// ⚠️ **只在存在样式编辑目标时登记控件几何**(用户 2026-11-02 报告):
// `LayoutStyleControls()` 无条件算出这些矩形,而绘制在“没有目标”时只画占位文字。
// 旧实现照样上报 ⇒ 空面板上打开「显示布局边界」会看到一整排并不存在的控件框。
const bool hasStyleTarget = HasStyleTarget();
if (hasStyleTarget)
{
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);
}
// ── 「目标」行(样式区顶部,展示当前选中信号) ────────────────────
// 这一行**无论是否选中**都会绘制(未选中时显示占位文本),所以本体总是登记;
// 但信号名 / GX 徽标只在实际有目标时存在。
boxes.Add(m_targetRowRect, 2);
if (hasStyleTarget)
{
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::BuildFilterMask(int groupIndex, std::vector<bool>& mask) const
{
mask.clear();
if (m_filterText.empty() || m_dataSet == nullptr || groupIndex < 0)
{
return false; // 未筛选:调用方走"整组"语义
}
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return false;
}
mask.reserve(group->signals.size());
for (const SignalData& signal : group->signals)
{
mask.push_back(FilterMatchesSignal(signal));
}
return true;
}
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;
}
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;
// 记住“本行集合对应的定义状态”(含空数据集的早退路径),供 `EnsureRows()` 做自愈校验。
m_rowsSignature = RowsSignature();
// 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。
// 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。
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();
}
std::uint64_t SignalPanel::RowsSignature() const
{
// FNV-1a:混合“每个分组是否列出、列出多少项、是否折起、是否有折起动画”。
// 这四项恰好完全决定行集合的内容,因此它们不变时无需重建。
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash = (hash ^ value) * 1099511628211ull;
};
if (m_dataSet == nullptr || m_tab == nullptr)
{
return hash;
}
const std::vector<SignalGroup>& groups = m_dataSet->Groups();
mix(static_cast<std::uint64_t>(groups.size() + 1));
// 筛选态下“每组列多少项”要逐信号比对(成本 O(信号数)),因此只在非筛选时入签名;
// 筛选文本变化本身会走 `ApplyFilterText()` 置脏,无需靠签名发现。
const bool filtering = !m_filterText.empty();
mix(filtering ? 1ull : 0ull);
for (std::size_t index = 0; index < groups.size(); ++index)
{
const int groupIndex = static_cast<int>(index);
const std::uint64_t count =
filtering ? 0u : static_cast<std::uint64_t>(groups[index].signals.size());
mix(count + 2u);
mix(IsGroupCollapsed(groupIndex) ? 3u : 0u);
mix((FindGroupAnimation(groupIndex) >= 0) ? 5u : 0u);
}
return hash;
}
void SignalPanel::EnsureRows()
{
// ⚠️ 动画推进只依赖挂钟时间(`AdvanceGroupAnimations`),而驱动它的 `WM_TIMER` 是
// **最低优先级**的消息:绘图区若在自持刷新(例如边沿统计未完成时不断请求新帧),
// `WM_TIMER` 会被饿死 ⇒ 折起动画永远结束不了 ⇒ 组名三角已经显示“已收起”,
// 但行集合里仍保留着该组的信号行(可被点选)。用户 2026-11-02 报告的正是这个现象。
// 因此绘制 / 命中路径也顺手推进一步:动画是否结束只看时间,不依赖消息到达。
if (!m_groupAnims.empty())
{
AdvanceGroupAnimations();
}
if (m_rowsDirty)
{
BuildRows();
RefreshScrollBar();
return;
}
// ⚠️ **自愈不变量**:行集合必须与“分组集合 / 每组可见项数 / 折起状态 / 是否有动画”
// 严格一致。历史上确实出现过“折起状态变了但忘了置 `m_rowsDirty`”的路径 ——
// 表现就是用户 2026-10-30 报告的:组名三角已经显示“展开”,
// 但下属信号行一条都不出现(行集合里根本没有它们)。
// 这里每帧花 O(分组数) 校验一次签名,不一致就地重建,不再依赖每个调用点自觉置脏。
if (m_rowsSignature != RowsSignature())
{
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];
}
// 信号条目拖拽排序的**避让位移**(用户 2026-11-03 要求):
// 本函数是行内全部派生的唯一几何来源,因此只需在这里叠加一次,
// 名称框 / 复选框 / 徽标 / 关闭 ✕ 等会自动跟随,形成"其它条目让位"的动画。
if (rowIndex < m_signalDragShiftCur.size())
{
top += m_signalDragShiftCur[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;
}
bool SignalPanel::HasStyleTarget() const
{
// 与 `PaintStyleSection` / `OnLButtonDown` 的判据**完全同源**:选中信号且样式可解析。
if (m_tab == nullptr || m_dataSet == nullptr)
{
return false;
}
const SignalRef& selected = SelectedRef();
return selected.IsValid() && StyleOf(selected) != 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