// 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(x), static_cast(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(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(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(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(groupIndex) >= m_filterCollapsed.size()) { return false; } return m_filterCollapsed[static_cast(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(groupIndex) >= m_filterCollapsed.size()) { m_filterCollapsed.resize(static_cast(groupIndex) + 1, false); } m_filterCollapsed[static_cast(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(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(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(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(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(elapsed) / static_cast(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(m_rowShift.size()); ++row) { m_rowShift[static_cast(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(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(anim.rowCount))); anim.durationMs = static_cast(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(m_rows.size())) { return -1.0e9f; } const ListRow& item = m_rows[static_cast(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(m_rows.size())) : RowRect(headerRow).Y; return headerTop + m_rows[static_cast(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& files = m_dataSet->Groups(); for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex) { const int gi = static_cast(groupIndex); // 参与列出的信号:筛选模式只保留匹配项;整组都没有匹配则**不显示该组**。 std::vector 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(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(shown.size()); m_rows.push_back(groupRow); // 折起且没有动画 ⇒ 该组的信号行完全不占高度(不出现在行集合里)。 // ⚠️ 有动画时必须**保留全部信号行**(含正在折起的):它们整组向上平移, // 逐条滑进组名栏并被裁掉 —— 行高不变,因此动画期间无需重建行集合。 if (collapsed && !animating) { continue; } for (int ordinal = 0; ordinal < static_cast(shown.size()); ++ordinal) { ListRow signalRow; signalRow.kind = RowKind::SIGNAL; signalRow.groupIndex = gi; signalRow.signalIndex = shown[static_cast(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& groups = m_dataSet->Groups(); mix(static_cast(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(index); const std::uint64_t count = filtering ? 0u : static_cast(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(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(row)].height); } float top = m_listRect.Y - m_scrollOffset; // O(1) 查表:前缀和由 `UpdateAnimGeometry()` 维护。 // ⚠️ 前缀和与行集合长度不一致只可能出现在"行集合刚变、几何还没更新"的极短窗口, // 此时退回逐行累加以保证结果仍然正确(不追求速度)。 const std::size_t rowIndex = static_cast(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(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(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(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(last)].height; return std::min(m_listRect.Y, lastBottom - m_rows[static_cast(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(m_rows.size()) || m_rows[static_cast(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(m_rows.size())) { return RowPart::NONE; } const ListRow& item = m_rows[static_cast(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(m_rows.size())); if (count <= 0) { return 0.0f; } if (m_rowTop.size() == m_rows.size() + 1) { return m_rowTop[static_cast(count)]; } float top = 0.0f; for (int index = 0; index < count; ++index) { top += m_rows[static_cast(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(kStyleRowCount); ++index) { m_styleRowRects[static_cast(index)] = gd::RectF(groupLeft, firstTop + static_cast(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(m_colorRects.size())); float swatchSize = kSwatchEdge * m_scale; float gap = swatchGap; while (count >= 1) { const float slots = static_cast(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(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(index)] = gd::RectF(colorRowLeft + static_cast(index) * (swatchSize + gap), swatchTop, swatchSize, swatchSize); } m_customChipRect = gd::RectF(colorRowLeft + static_cast(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(kModeOptionCount - 1)) / static_cast(kModeOptionCount)); for (int index = 0; index < kModeOptionCount; ++index) { m_modeRects[static_cast(index)] = gd::RectF(contentLeft + static_cast(index) * (modeWidth + modeGap), modeRow.Y, modeWidth, rowHeight); } // ---- 「调整」行:系数/偏移 · 镜像 · 绝对值(三个铺满内容列的等宽按钮)---- const float adjustGap = kAdjustGap * m_scale; const float adjustWidth = std::max(20.0f, (contentWidth - adjustGap * static_cast(kAdjustCount - 1)) / static_cast(kAdjustCount)); for (int index = 0; index < kAdjustCount; ++index) { m_adjustRects[static_cast(index)] = gd::RectF(contentLeft + static_cast(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(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(index)].kind == RowKind::GROUP) { topInset = m_rows[static_cast(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(m_rows.size())) { return gd::RectF(); } const gd::RectF rect = RowRect(row); const bool isGroup = (m_rows[static_cast(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(row)].groupIndex, m_rows[static_cast(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(m_rows.size()) || m_rows[static_cast(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(m_rows.size())) { return 0.0f; } return UiMeasureTextWidth(m_fonts->uiBold, GroupLabelText(m_rows[static_cast(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(m_rows.size())) { return std::wstring(); } // 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数 //(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。 return std::to_wstring(m_rows[static_cast(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(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