// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 // // 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 // 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。 #include "SignalPanelInternal.hpp" namespace jdi { bool SignalPanel::IsOnFilterBox(int x, int y) const { return UiPointInRect(m_filterRect, static_cast(x), static_cast(y)); } void SignalPanel::ClearFilter() { if (m_filterText.empty() && !m_filterEditing) { return; } m_filterText.clear(); if (m_filterEditing) { // 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。 m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u); } // 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。 m_filterCollapsed.clear(); m_groupAnims.clear(); m_rowShift.clear(); // 清空后回到完整列表:重建行集合、回到顶部。 m_rowsDirty = true; m_scrollOffset = 0.0f; m_layoutDirty = true; } HCURSOR SignalPanel::QueryCursor(int x, int y) { // 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。 const gd::RectF clearRect = FilterClearRect(); if (clearRect.Width > 0.0f && UiPointInRect(clearRect, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } // 信号行尾的「移除该信号」✕ 与分组行尾的「关闭该分组」✕ 视觉完全一致 ⇒ 光标必须一致。 RowPart part = RowPart::NONE; if (HitRow(x, y, part) >= 0 && (part == RowPart::REMOVE_SIGNAL || part == RowPart::CLOSE)) { return LoadCursorW(nullptr, IDC_HAND); } // 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。 const float fx = static_cast(x); const float fy = static_cast(y); // 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。 UpdateTargetRowGeometry(); if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) || (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))) { return LoadCursorW(nullptr, IDC_HAND); } if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || UiPointInRect(m_opacityValueRect, fx, fy)) { return LoadCursorW(nullptr, IDC_HAND); } return nullptr; } void SignalPanel::BeginFilterEdit() { if (m_filterEditing) { return; } m_filterEditing = true; // 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。 m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u); } bool SignalPanel::EndFilterEdit() { if (!m_filterEditing) { return false; } // **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。 const std::wstring finalText = Trim(m_filterEdit.Text()); m_filterEditing = false; m_filterEdit.End(); // ⚠️ 必须走 `ApplyFilterText`(而不是直接赋值):只赋值会让 `m_filterText` 变成 // “普通模式”而行集合仍是筛选后的结果(输入纯空格后回车 → 列表仍然缺分组/信号)。 ApplyFilterText(finalText); return true; } bool SignalPanel::AdvanceGroupAnimations() { if (m_groupAnims.empty()) { return false; } const std::uint64_t now = GetTickCount64(); bool animationFinished = false; for (std::size_t index = 0; index < m_groupAnims.size();) { const GroupAnim& anim = m_groupAnims[index]; const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0; if (anim.durationMs == 0u || elapsed >= anim.durationMs) { // 完成:把同步滚动精确落到终点(最后一帧的 eased 进度未必正好是 1)。 if (anim.scrollSynced) { m_scrollOffset = anim.scrollTo; } animationFinished = true; m_groupAnims.erase(m_groupAnims.begin() + static_cast(index)); } else { ++index; } } // ⚠️ **动画结束(折起与展开都要)后必须重建行集合**: // * 折起:该组的信号行要真正从行集合里移除; // * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”, // 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会 // 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。 // ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 —— // 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。 if (animationFinished) { m_rowsDirty = true; BuildRows(); // 内部会重算内容高度与逐行位移 } else { UpdateAnimGeometry(); } // 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间 // 列表宽度/滚动条与内容不匹配)。 // ⚠️ 动画进行中**不夹取**滚动偏移:与滚动同步的折起动画正按自己的进度推进 // `m_scrollOffset`,此时夹取会打断它。但动画**全部结束**后必须夹一次 —— 否则 // 在长列表底部折起一个可见分组时,内容高度变小而滚动量保持原值:列表底部出现空白条, // 且滚动条滑块按未夹取的 `progress` 被画到轨道之外。 RefreshScrollBar(animationFinished); return !m_groupAnims.empty(); } void SignalPanel::ToggleGroupFromRow(int groupIndex, int row) { EnsureRows(); if (m_tab == nullptr || groupIndex < 0) { return; } const int headerRow = (row >= 0 && row < static_cast(m_rows.size()) && m_rows[static_cast(row)].kind == RowKind::GROUP && m_rows[static_cast(row)].groupIndex == groupIndex) ? row : GroupRowIndex(groupIndex); const bool collapse = !IsGroupCollapsed(groupIndex); SetGroupCollapsed(groupIndex, collapse); // 悬浮(组名的自然位置已在列表上边界之上)且要折起时:**一边向上滚动一边收起**, // 保证悬浮组名始终留在列表最上方,动画结束时滚动偏移恰好落在“组名正好在最上方”。 //(用户 2026-10-16 第二轮明确要求:不再“先滚动到位、再播放收起动画”。) float scrollFrom = m_scrollOffset; float scrollTo = m_scrollOffset; bool syncScroll = false; if (collapse && headerRow >= 0) { const float alignOffset = ContentOffsetBeforeRow(headerRow); if (m_scrollOffset > alignOffset + 0.5f) { // 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短, // 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。 const float finalContent = std::max(0.0f, NaturalContentHeight() - ShownBlockHeight(groupIndex)); const float maxOffset = std::max(0.0f, finalContent - m_listRect.Height); scrollTo = std::max(0.0f, std::min(alignOffset, maxOffset)); syncScroll = true; } } StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll); } int SignalPanel::HitRow(int x, int y, RowPart& part) { part = RowPart::NONE; EnsureRows(); if (m_listRect.Height <= 0.0f) { return -1; } const float fx = static_cast(x); const float fy = static_cast(y); if (fy < m_listRect.Y || fy > m_listRect.Y + m_listRect.Height) { return -1; } if (fx < m_listRect.X || fx > m_listRect.X + m_listRect.Width) { return -1; } // 「悬浮组名」绘制在最上层,因此命中判定也必须最优先 —— 否则点击会**穿透到 // 它下面的那一行**(用户 2026-10-16 报告:组名悬浮时它上面的折叠三角、复选框、 // 关闭 ✕、名称区域全都点不动,点击被下层信号行吃掉)。 const int rowCount = static_cast(m_rows.size()); const int stickyRow = StickyGroupRowIndex(rowCount); if (stickyRow >= 0) { const float stickyTop = StickyGroupRowTop(stickyRow, rowCount); const float stickyHeight = m_rows[static_cast(stickyRow)].height; if (fy >= stickyTop && fy < stickyTop + stickyHeight) { // 用覆盖几何计算行内区域(`RowRect` 是行内所有派生的唯一几何来源)。 m_rowYOverrideActive = true; m_rowYOverrideY = stickyTop; part = RowHitPart(stickyRow, fx, fy); m_rowYOverrideActive = false; return stickyRow; } } // 逐行比较“实际绘制位置”(含折起/展开动画的刚性平移),不能只看滚动偏移: // 动画中信号行的位置与“自然内容坐标”不再一一对应,用后者会点错行。 float rowTop = m_listRect.Y - m_scrollOffset; for (std::size_t index = 0; index < m_rows.size(); ++index) { const float shift = (index < m_rowShift.size()) ? m_rowShift[index] : 0.0f; const float previousShift = (index > 0 && (index - 1) < m_rowShift.size()) ? m_rowShift[index - 1] : 0.0f; rowTop -= (shift - previousShift); const ListRow& row = m_rows[index]; const float rowBottom = rowTop + row.height; if (row.height > 0.0f && fy >= rowTop && fy < rowBottom) { const int rowIndex = static_cast(index); // 折起/展开动画中已“藏进组名栏”的行不再参与命中(否则它会白白吸走一次点击)。 if (rowBottom > AnimationHideTopForRow(rowIndex, stickyRow) + 0.5f) { part = RowHitPart(rowIndex, fx, fy); return rowIndex; } return -1; } rowTop = rowBottom; } return -1; } void SignalPanel::StartScrollAnimationTo(float desired) { const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height); desired = std::max(0.0f, std::min(desired, maxOffset)); const float distance = std::fabs(desired - m_scrollOffset); if (distance < 1.0f) { // 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。 m_scrollOffset = desired; ClampScroll(); m_scrollAnimActive = false; return; } // 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长, // 因此"整个动画过程不会太久"(最长 300ms)。 const float durationMs = std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel)); m_scrollAnimFrom = m_scrollOffset; m_scrollAnimTo = desired; m_scrollAnimStartMs = GetTickCount64(); m_scrollAnimDurationMs = static_cast(durationMs + 0.5f); m_scrollAnimActive = true; } void SignalPanel::ScrollToSignal(const SignalRef& ref) { EnsureRows(); if (!ref.IsValid()) { return; } // ⚠️ 必须与动画版(`SignalScrollTarget`)共用同一套几何:非动画版原来把目标行直接顶到 // 列表上边界,而该区域被**悬浮组名**占据(`StickyGroupRowIndex`),于是切标签页后 // 选中的信号会被完全遮住。这里直接复用 `SignalScrollTarget`(含 `topInset` 避让)。 // ⚠️ 折起的组同样要先展开,否则行集合里根本没有目标行。 ExpandGroupForSignal(ref); float desired = m_scrollOffset; if (SignalScrollTarget(ref, desired)) { m_scrollOffset = desired; ClampScroll(); } m_scrollAnimActive = false; } void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref) { EnsureRows(); if (!ref.IsValid()) { return; } // ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。 // 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。 // 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。 ExpandGroupForSignal(ref); float desired = m_scrollOffset; if (!SignalScrollTarget(ref, desired)) { return; } // 把列表平滑滚动到目标信号完整可见。 StartScrollAnimationTo(desired); } bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref) { if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex)) { return false; } SetGroupCollapsed(ref.groupIndex, false); // 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。 const int anim = FindGroupAnimation(ref.groupIndex); if (anim >= 0) { m_groupAnims.erase(m_groupAnims.begin() + anim); } // 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。 m_rowsDirty = true; EnsureRows(); return true; } bool SignalPanel::AdvanceScrollAnimation() { if (!m_scrollAnimActive) { return false; } const std::uint64_t now = GetTickCount64(); const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0; float progress = (m_scrollAnimDurationMs > 0u) ? static_cast(elapsed) / static_cast(m_scrollAnimDurationMs) : 1.0f; if (progress > 1.0f) { progress = 1.0f; } // ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。 const float eased = 1.0f - std::pow(1.0f - progress, 3.0f); m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased; if (progress >= 1.0f) { m_scrollOffset = m_scrollAnimTo; m_scrollAnimActive = false; ClampScroll(); return false; } return true; } bool SignalPanel::ApplyWidthFromX(float x) { // ⚠️ 判的是**实际被解引用**的指针:本函数只经 `StyleOf()` 碰 `m_dataSetWritable`, // 与 `m_tab` 无关(旧实现在这里判 `m_tab`,真出现“有 tab、无 dataset”时仍会空指针)。 if (m_dataSetWritable == nullptr) { return false; } SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } const double ratio = UiSliderRatioFromX(m_widthTrackRect, x); float value = static_cast(kWidthMin + ratio * (kWidthMax - kWidthMin)); value = std::round(value * 10.0f) / 10.0f; value = ClampFloat(value, kWidthMin, kWidthMax); if (std::fabs(value - style->lineWidth) < 0.001f) { return false; } style->lineWidth = value; return true; } bool SignalPanel::BeginEdit(StyleEditField field, int row) { // `m_dataSet` 在下面的 `Resolve()` / `GroupAt()` 里被直接解引用,必须一并判空。 if (m_tab == nullptr || m_dataSet == nullptr || field == StyleEditField::NONE) { return false; } if (m_editField == field && m_editRow == row) { return false; } std::wstring initial; switch (field) { case StyleEditField::WIDTH: { const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } initial = FormatNumber(style->lineWidth, 1); break; } case StyleEditField::OPACITY: { const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } initial = std::to_wstring(static_cast(style->opacity * 100.0f + 0.5f)); break; } case StyleEditField::SIGNAL_NAME: { // `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。 if (row < 0) { const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr; if (signal == nullptr) { return false; } initial = signal->name; break; } if (row >= static_cast(m_rows.size())) { return false; } const ListRow& item = m_rows[static_cast(row)]; const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex}); if (signal == nullptr) { return false; } initial = signal->name; break; } case StyleEditField::GROUP_TITLE: { if (row < 0 || row >= static_cast(m_rows.size())) { return false; } const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast(row)].groupIndex); if (group == nullptr) { return false; } initial = group->name; break; } default: return false; } m_editField = field; m_editRow = row; if (field == StyleEditField::SIGNAL_NAME && row < 0) { // 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。 UpdateTargetRowGeometry(); } // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 const bool nameField = (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::GROUP_TITLE); // 不限长度:名称与数值都由各自的提交逻辑处理超长内容。 m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); return true; } bool SignalPanel::EndEdit(bool commit) { if (m_editField == StyleEditField::NONE) { return false; } const StyleEditField field = m_editField; const int row = m_editRow; const std::wstring text = m_edit.Text(); m_editField = StyleEditField::NONE; m_editRow = -1; m_edit.End(); if (!commit || m_tab == nullptr || m_dataSet == nullptr) { return true; } const std::wstring trimmed = Trim(text); switch (field) { case StyleEditField::WIDTH: { if (trimmed.empty()) { break; } wchar_t* stop = nullptr; const double parsed = std::wcstod(trimmed.c_str(), &stop); if (stop == trimmed.c_str() || parsed != parsed) { break; } if (SignalStyle* style = StyleOf(SelectedRef())) { const float value = ClampFloat(static_cast(parsed), kWidthMin, kWidthMax); style->lineWidth = std::round(value * 10.0f) / 10.0f; } break; } case StyleEditField::OPACITY: { if (trimmed.empty()) { break; } wchar_t* stop = nullptr; const double parsed = std::wcstod(trimmed.c_str(), &stop); if (stop == trimmed.c_str() || parsed != parsed) { break; } if (SignalStyle* style = StyleOf(SelectedRef())) { style->opacity = ClampFloat(static_cast(parsed) / 100.0f, 0.0f, 1.0f); } break; } case StyleEditField::SIGNAL_NAME: { if (m_dataSetWritable == nullptr) { break; } if (row < 0) { // 样式区「目标」行:重命名当前选中的(目标)信号。 const SignalRef& target = SelectedRef(); if (target.IsValid()) { m_dataSetWritable->RenameSignal(target.groupIndex, target.signalIndex, trimmed); } // 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。 m_targetGeomValid = false; break; } if (row >= static_cast(m_rows.size())) { break; } const ListRow& item = m_rows[static_cast(row)]; m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed); break; } case StyleEditField::GROUP_TITLE: { if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) { break; } m_dataSetWritable->RenameGroup(m_rows[static_cast(row)].groupIndex, trimmed); break; } default: break; } return true; } bool SignalPanel::OnEditChar(wchar_t character) { // 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。 if (m_filterEditing) { switch (character) { case 0x03: // Ctrl+C m_filterEdit.CopyToClipboard(m_host); return false; case 0x18: // Ctrl+X m_filterEdit.CutToClipboard(m_host); ApplyFilterText(m_filterEdit.Text()); return true; case 0x16: // Ctrl+V m_filterEdit.PasteFromClipboard(m_host); ApplyFilterText(m_filterEdit.Text()); return true; case 0x01: // Ctrl+A m_filterEdit.SelectAll(); return true; default: break; } if (character == L'\r' || character == L'\n') { return EndFilterEdit(); } // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,这里忽略控制字符避免重复删除。 if (character < 0x20 || character == 0x7F) { return false; } if (!m_filterEdit.Insert(character)) { return false; } ApplyFilterText(m_filterEdit.Text()); return true; } if (m_editField == StyleEditField::NONE) { return false; } // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 switch (character) { case 0x03: // Ctrl+C m_edit.CopyToClipboard(m_host); return false; case 0x18: // Ctrl+X return m_edit.CutToClipboard(m_host); case 0x16: // Ctrl+V return m_edit.PasteFromClipboard(m_host); case 0x01: // Ctrl+A m_edit.SelectAll(); return true; default: break; } if (character == L'\r' || character == L'\n') { return EndEdit(true); } // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理:这里必须忽略控制字符, // 否则同一次按键会被处理两次(一次删除两个字符)。 if (character < 0x20 || character == 0x7F) { return false; } // 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。 if (m_editField == StyleEditField::OPACITY && character == L'%') { return false; } return m_edit.Insert(character); } bool SignalPanel::OnEditKey(WPARAM key) { // 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。 if (m_filterEditing) { const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; switch (key) { case VK_RETURN: return EndFilterEdit(); case VK_ESCAPE: { const bool changed = m_filterEdit.Text() != m_filterText; EndFilterEdit(); return changed; } case VK_BACK: { if (!m_filterEdit.Backspace()) { return false; } ApplyFilterText(m_filterEdit.Text()); return true; } case VK_DELETE: { if (!m_filterEdit.DeleteForward()) { return false; } ApplyFilterText(m_filterEdit.Text()); return true; } case VK_LEFT: return m_filterEdit.MoveCaret(-1, shift); case VK_RIGHT: return m_filterEdit.MoveCaret(1, shift); case VK_HOME: return m_filterEdit.MoveHome(shift); case VK_END: return m_filterEdit.MoveEnd(shift); case 'A': if (control) { m_filterEdit.SelectAll(); return true; } break; default: break; } return true; } if (m_editField == StyleEditField::NONE) { return false; } const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; switch (key) { case VK_RETURN: return EndEdit(true); case VK_ESCAPE: return EndEdit(false); case VK_BACK: return m_edit.Backspace(); case VK_DELETE: return m_edit.DeleteForward(); case VK_LEFT: return m_edit.MoveCaret(-1, shift); case VK_RIGHT: return m_edit.MoveCaret(1, shift); case VK_HOME: return m_edit.MoveHome(shift); case VK_END: return m_edit.MoveEnd(shift); case 'A': if (control) { m_edit.SelectAll(); return true; } break; default: break; } return true; } bool SignalPanel::CollapseEditSelectionToEnd() { if (m_filterEditing) { return m_filterEdit.DeselectToEnd(); } if (m_editField == StyleEditField::NONE) { return false; } return m_edit.DeselectToEnd(); } bool SignalPanel::OnEditMouseDown(int x, int y) { if (m_filterEditing) { // 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理, // 否则点击会被当成"把光标移到此处"而清空不了内容。 // 与其它 ✕ 一致:按下只记录目标,抬起时仍在 ✕ 上才真正清空。 const gd::RectF clearRect = FilterClearRect(); if (clearRect.Width > 0.0f && UiPointInRect(clearRect, static_cast(x), static_cast(y))) { m_pendingFilterClear = true; return true; } return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y); } if (m_editField == StyleEditField::NONE) { return false; } // ⚠️⚠️ **编辑态下,按在被编辑的那一行名称区域内(含输入框上下各 3px 的行内边距) // 都必须当作“文本操作”**,不能穿透成行点击。否则:容器行高 > 输入框高度, // 鼠标起点只要落在内边距上就会走 `OnLButtonDown` 的分组分支 ⇒ **分组被展开/收起** //(用户 2026-10-19 报告:“框选编辑文本时组会展开、收起”)。 // 横向仍限定在名称区域内:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ 继续可点。 const gd::RectF field = EditFieldRect(); if (field.Width > 0.0f) { const gd::RectF absorb = NameEditAbsorbRect(field); if (UiPointInRect(absorb, static_cast(x), static_cast(y)) && !UiPointInRect(field, static_cast(x), static_cast(y))) { // 把落点夹回输入框,保证插入符换算与绘制使用同一套几何 //(`UiEditFieldMouseDown` 自己会再算一次文本起点)。 const int clampedX = static_cast(std::lround( std::max(field.X + 1.0f, std::min(static_cast(x), field.X + field.Width - 2.0f)))); const int clampedY = static_cast(std::lround( std::max(field.Y + 1.0f, std::min(static_cast(y), field.Y + field.Height - 2.0f)))); return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, clampedX, clampedY); } } return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, x, y); } bool SignalPanel::OnEditMouseMove(int x) { if (m_filterEditing) { return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x); } if (m_editField == StyleEditField::NONE) { return false; } return UiEditFieldMouseMove(EditFieldFont(), EditFieldRect(), m_edit, m_scale, x); } bool SignalPanel::OnEditMouseUp(int x, int y) { if (m_filterEditing) { // 筛选框右端的清空 ✕ 在编辑态下的**按下**走 OnEditMouseDown(优先于插入符定位), // 抬起同样由宿主送进 EDIT_FIELD 分支,因此确认逻辑要在这里结算: // 只有抬起时指针仍在 ✕ 上才真正清空,移开后松开视为放弃。 if (m_pendingFilterClear) { m_pendingFilterClear = false; const gd::RectF clearRect = FilterClearRect(); if (clearRect.Width > 0.0f && UiPointInRect(clearRect, static_cast(x), static_cast(y))) { ClearFilter(); return true; } } if (!m_filterEdit.Dragging()) { return false; } m_filterEdit.EndDrag(); return true; } if (!m_edit.Dragging()) { return false; } m_edit.EndDrag(); return true; } void SignalPanel::SelectSignal(const SignalRef& ref) { if (m_dataSetWritable != nullptr) { m_dataSetWritable->SetSelected(ref); } } bool SignalPanel::ApplyColor(std::uint32_t color) { // 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。 if (m_dataSetWritable == nullptr) { return false; } SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } // 同时记住最后一次自定义取值,便于切换到预设色后一键回到该颜色。 const bool changed = (style->color != color) || (style->customColor != color); style->color = color; style->customColor = color; return changed; } bool SignalPanel::ApplyOpacityFromX(float x) { // 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。 if (m_dataSetWritable == nullptr) { return false; } SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } const float value = static_cast(UiSliderRatioFromX(m_opacityTrackRect, x)); if (std::fabs(value - style->opacity) < 0.005f) { return false; } style->opacity = value; return true; } bool SignalPanel::OpenGainMenu() { if (m_menu == nullptr || m_tab == nullptr) { return false; } const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } // 二级面板:两张“标题(带还原图标)+ 输入框”的组合: // 1. 「系数调整」:一整行系数输入框; // 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。 //(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。) std::vector items; MenuItem gainHeader; gainHeader.kind = MenuItemKind::HEADER_ACTION; gainHeader.label = L"系数调整"; gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1 items.push_back(gainHeader); // 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。 const std::wstring gainText = FormatGainText(style->gain); const std::wstring gainEditText = FormatNumberText(static_cast(style->gain)); MenuItem gainEdit; gainEdit.kind = MenuItemKind::EDIT; gainEdit.label.clear(); // 不显示说明文字:输入框独占整行 gainEdit.id = CMD_GAIN_EDIT; gainEdit.value = gainText; gainEdit.editValue = gainEditText; gainEdit.filter = EditFilter::NUMBER; items.push_back(gainEdit); MenuItem offsetHeader; offsetHeader.kind = MenuItemKind::HEADER_ACTION; offsetHeader.label = L"偏移调整"; offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值 items.push_back(offsetHeader); // 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。 MenuItem offsetY; offsetY.kind = MenuItemKind::EDIT; offsetY.label = L"Y轴"; offsetY.id = CMD_OFFSET_Y; offsetY.value = FormatSignedNumber(static_cast(style->offsetY)); offsetY.editValue = FormatNumberText(static_cast(style->offsetY)); offsetY.filter = EditFilter::NUMBER; items.push_back(offsetY); // 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。 // 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求); // 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。 MenuItem offsetTime; offsetTime.kind = MenuItemKind::EDIT; offsetTime.label = L"时间轴"; offsetTime.id = CMD_OFFSET_TIME; offsetTime.value = FormatOffsetTimeText(style->offsetTime); offsetTime.editValue = FormatNumberText(style->offsetTime); offsetTime.editSuffix = L"s"; offsetTime.filter = EditFilter::NUMBER; items.push_back(offsetTime); // 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角, // 并再往上让出 `kAdjustMenuGap`(用户明确要求)。 //(旧实现在按钮下沿向右下展开,面板会盖住下方内容。) const gd::RectF& button = m_adjustRects[kAdjustGain]; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; const int anchorY = static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, true); // ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE → // 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。 ClearStyleHover(); return true; } bool SignalPanel::OpenMirrorMenu() { if (m_menu == nullptr || m_tab == nullptr) { return false; } const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } // 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。 std::vector items; MenuItem horizontal; horizontal.kind = MenuItemKind::NORMAL; horizontal.label = L"水平镜像"; horizontal.id = CMD_MIRROR_HORIZONTAL; horizontal.checked = style->mirrorHorizontal; horizontal.keepOpen = true; items.push_back(horizontal); MenuItem vertical; vertical.kind = MenuItemKind::NORMAL; vertical.label = L"垂直镜像"; vertical.id = CMD_MIRROR_VERTICAL; vertical.checked = style->mirrorVertical; vertical.keepOpen = true; items.push_back(vertical); const gd::RectF& button = m_adjustRects[kAdjustMirror]; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; // 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。 const int anchorY = static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, true); ClearStyleHover(); return true; } void SignalPanel::ClearStyleHover() { if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0) { return; } m_hoverStyle = StyleHit::NONE; m_hoverStyleIndex = -1; } bool SignalPanel::CloseTransientUi() { bool changed = false; if (m_editField != StyleEditField::NONE) { changed = EndEdit(true) || changed; } // 点击面板之外也要结束「筛选信号」的编辑状态。 if (m_filterEditing) { changed = EndFilterEdit() || changed; } if (m_colorPicker != nullptr && m_colorPicker->IsOpen()) { m_colorPicker->Close(); changed = true; } // 面板外点击:取消尚未抬起确认的关闭操作。 m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; m_pendingFilterClear = false; m_pendingGainMenu = false; m_pendingMirrorMenu = false; m_pendingTargetLaneMenu = false; return changed; } bool SignalPanel::OnMouseMove(int x, int y) { // 拖拽优先级最高。 if (m_draggingOpacity) { return ApplyOpacityFromX(static_cast(x)); } if (m_draggingWidth) { return ApplyWidthFromX(static_cast(x)); } if (m_draggingScrollBar) { const gd::RectF thumb = ScrollThumbRect(); // ⚠️ 拖拽期间滑块高度不会变(内容高度与列表高度都没变),因此 `thumb.Height` 可安全 // 用于本次换算;但 `m_scrollBarRect.Y` 必须取**按下时**记录的值(局部刷新/重排可能 // 改掉它)。坐标一律用**客户区** y,因此鼠标移到面板外(如绘图区)也能继续跟随。 const float travel = static_cast(y - m_scrollGrabOffset) - m_scrollBarRect.Y; const float usable = m_scrollBarRect.Height - thumb.Height; if (usable > 0.0f) { const float progress = std::max(0.0f, std::min(1.0f, travel / usable)); m_scrollOffset = progress * (m_contentHeight - m_listRect.Height); ClampScroll(); } return true; } RowPart part = RowPart::NONE; const int hoverRow = HitRow(x, y, part); StyleHit hoverStyle = StyleHit::NONE; int hoverStyleIndex = -1; const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_toggleAllRect, fx, fy)) { hoverStyle = StyleHit::TOGGLE_ALL; } else if (UiPointInRect(FilterClearRect(), fx, fy)) { // 筛选框右侧的清空 ✕(仅在筛选框有内容时存在)。 hoverStyle = StyleHit::FILTER_CLEAR; } else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr) { // 「目标」行的信号名 / GX 标签(优先于其余样式控件:它们几何不重叠, // 但显式放在最前更易读)。 UpdateTargetRowGeometry(); if (UiPointInRect(m_targetRowRect, fx, fy)) { if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)) { hoverStyle = StyleHit::TARGET_BADGE; } else if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) { hoverStyle = StyleHit::TARGET_NAME; } } for (int index = 0; hoverStyle == StyleHit::NONE && index < m_colorCount; ++index) { if (UiPointInRect(m_colorRects[static_cast(index)], fx, fy)) { hoverStyle = StyleHit::COLOR; hoverStyleIndex = index; break; } } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_customChipRect, fx, fy)) { hoverStyle = StyleHit::CUSTOM_CHIP; } if (hoverStyle == StyleHit::NONE) { for (int index = 0; index < kModeOptionCount; ++index) { // 置灰的选项(单点信号的「线段」/「点线」)不参与悬停高亮 —— // 它们点不动,高亮会让用户以为可以点击。 if (ModeOptionEnabled(index) && UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { hoverStyle = StyleHit::MODE; hoverStyleIndex = index; break; } } } if (hoverStyle == StyleHit::NONE && UiPointInRect(SliderHitRect(m_opacityTrackRect, m_scale), fx, fy)) { hoverStyle = StyleHit::OPACITY_SLIDER; } if (hoverStyle == StyleHit::NONE && UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy)) { hoverStyle = StyleHit::WIDTH_SLIDER; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_opacityValueRect, fx, fy)) { hoverStyle = StyleHit::OPACITY_VALUE; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthValueRect, fx, fy)) { hoverStyle = StyleHit::WIDTH_VALUE; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) { hoverStyle = StyleHit::GAIN_ADJUST; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy)) { hoverStyle = StyleHit::MIRROR_ADJUST; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy)) { hoverStyle = StyleHit::ABSOLUTE_TOGGLE; } } if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex && part == m_hoverPart) { return false; } m_hoverRow = hoverRow; m_hoverPart = part; m_hoverStyle = hoverStyle; m_hoverStyleIndex = hoverStyleIndex; // 供绘图区联动高亮的悬停信号(用户 2026-10-15 要求): // * 列表里悬停某条信号行 → 高亮它所在的网格 + 该信号的曲线; // * 样式区「目标」行的信号名 / GX 标签上悬停 → 同样联动高亮。 // ⚠️ ✕(移除该信号)不算"悬停到该信号":它是一条即将被删除的信号, // 高亮它会让人误以为要保留它。 SignalRef hovered; if (hoverRow >= 0 && hoverRow < static_cast(m_rows.size()) && m_rows[static_cast(hoverRow)].kind == RowKind::SIGNAL && part != RowPart::REMOVE_SIGNAL) { hovered = SignalRef{m_rows[static_cast(hoverRow)].groupIndex, m_rows[static_cast(hoverRow)].signalIndex}; } else if (hoverStyle == StyleHit::TARGET_NAME || hoverStyle == StyleHit::TARGET_BADGE) { hovered = SelectedRef(); } m_hoveredSignal = hovered; return true; } bool SignalPanel::OnLButtonDown(int x, int y) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } const float fx = static_cast(x); const float fy = static_cast(y); // 新的按下开始:上一轮留下的"待确认关闭"一律作废。 m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; m_pendingFilterClear = false; // ⚠️⚠️ **内联改名期间,名称区域整行都属于“文本操作区”**: // 行高大于输入框高度(上下各留 3 逻辑像素),鼠标起点落在内边距上时旧实现会继续往下走 // 到分组行分支 ⇒ 分组被展开/收起(用户 2026-10-19 报告)。 // 这里直接按“文本按下”处理(定位插入符 / 开始拖拽选区),绝不触发行点击。 if (m_editField != StyleEditField::NONE && m_editRow >= 0 && m_editRow < static_cast(m_rows.size())) { const gd::RectF field = EditFieldRect(); if (field.Width > 0.0f && UiPointInRect(NameEditAbsorbRect(field), fx, fy)) { OnEditMouseDown(x, y); return true; } } // 「筛选信号」输入框:单击进入编辑;点击其它位置则先结束筛选编辑(文本已实时生效)。 if (IsOnFilterBox(x, y)) { // 右侧 ✕:**抬起**时才清空(不退出编辑态,便于继续输入); // 按下后移开再松开视为放弃。 if (UiPointInRect(FilterClearRect(), fx, fy)) { m_pendingFilterClear = true; return true; } BeginFilterEdit(); return true; } if (m_filterEditing) { // 文本已实时生效,这里只需收起编辑态,随后继续处理本次点击。 EndFilterEdit(); } // 滚动条拖拽优先于其它命中测试。 if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy)) { CancelScrollAnimation(); const gd::RectF thumb = ScrollThumbRect(); m_draggingScrollBar = true; if (UiPointInRect(thumb, fx, fy)) { m_scrollGrabOffset = y - static_cast(thumb.Y); } else { m_scrollGrabOffset = static_cast(thumb.Height * 0.5f); const float travel = fy - m_scrollBarRect.Y - static_cast(m_scrollGrabOffset); const float usable = m_scrollBarRect.Height - thumb.Height; if (usable > 0.0f) { const float progress = std::max(0.0f, std::min(1.0f, travel / usable)); m_scrollOffset = progress * (m_contentHeight - m_listRect.Height); ClampScroll(); } } return true; } m_pendingLaneMenu = false; m_pendingColorPicker = false; m_pendingGainMenu = false; m_pendingMirrorMenu = false; m_pendingTargetLaneMenu = false; // 点击当前输入框以外的位置时提交输入。 if (m_editField != StyleEditField::NONE && !UiPointInRect(EditFieldRect(), fx, fy)) { EndEdit(true); } // 全部显示/隐藏。 if (UiPointInRect(m_toggleAllRect, fx, fy)) { m_tab->SetAllVisible(!AreAllSignalsVisible()); m_rowsDirty = true; return true; } // 样式编辑区。 if (SignalStyle* style = StyleOf(SelectedRef())) { // ---- 顶层「目标」行 ---- // 信号名:单击**不进入编辑**(用户明确要求),只做联动(列表滚动 + 绘图区滚动到该网格); // GX 标签:单击展开「所属网格」二级菜单(与信号列表行结尾的 GX 徽标**同一份菜单**)。 // ⚠️ GX 标签**不做**联动滚动(用户 2026-10-16 要求):点击即开菜单、保持原地, // 但悬停仍然联动高亮(见 `OnMouseMove`)。 { UpdateTargetRowGeometry(); const bool onName = m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy); const bool onBadge = m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy); if (onBadge) { // 与网格徽标一致:按下只登记,**左键抬起**且指针仍在同一个徽标上时才展开菜单。 m_pendingTargetLaneMenu = true; return true; } if (onName) { ScrollToSignalAnimated(SelectedRef()); RequestPlotLaneScroll(SelectedRef()); return true; } } int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); for (int index = 0; index < m_colorCount; ++index) { if (UiPointInRect(m_colorRects[static_cast(index)], fx, fy)) { style->color = palette[index % paletteCount]; // 联动:样式自定义区的任何交互都把列表滚动到正在编辑的目标信号。 ScrollToSignalAnimated(SelectedRef()); return true; } } if (UiPointInRect(m_customChipRect, fx, fy)) { // 先恢复到记忆的自定义颜色(即便随后收起浮窗也会生效),再在抬起时展开浮窗。 style->color = style->customColor; m_pendingColorPicker = true; ScrollToSignalAnimated(SelectedRef()); return true; } for (int index = 0; index < kModeOptionCount; ++index) { if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { // 单点信号的「线段」/「点线」两项置灰 ⇒ 点了不生效(也不做联动滚动)。 if (!ModeOptionEnabled(index)) { return true; } style->mode = kModeOptions[index]; ScrollToSignalAnimated(SelectedRef()); return true; } } if (UiPointInRect(SliderHitRect(m_opacityTrackRect, m_scale), fx, fy)) { m_draggingOpacity = true; ApplyOpacityFromX(fx); ScrollToSignalAnimated(SelectedRef()); return true; } if (UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy)) { m_draggingWidth = true; ApplyWidthFromX(fx); ScrollToSignalAnimated(SelectedRef()); return true; } // 数值框的命中优先于滑块:两者几何重叠时要能进入输入状态。 if (UiPointInRect(m_widthValueRect, fx, fy)) { ScrollToSignalAnimated(SelectedRef()); return BeginEdit(StyleEditField::WIDTH); } if (UiPointInRect(m_opacityValueRect, fx, fy)) { ScrollToSignalAnimated(SelectedRef()); return BeginEdit(StyleEditField::OPACITY); } // 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板, // 与自定义颜色入口同一套交互)、绝对值(单击即切换)。 if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) { m_pendingGainMenu = true; ScrollToSignalAnimated(SelectedRef()); return true; } if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy)) { m_pendingMirrorMenu = true; ScrollToSignalAnimated(SelectedRef()); return true; } if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy)) { style->absolute = !style->absolute; ScrollToSignalAnimated(SelectedRef()); return true; } } // 列表行。 RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row < 0) { return false; } const ListRow item = m_rows[static_cast(row)]; if (item.kind == RowKind::GROUP) { if (part == RowPart::CHECK) { const bool allVisible = m_tab->GroupAllVisible(item.groupIndex); m_tab->SetGroupVisible(item.groupIndex, !allVisible); m_rowsDirty = true; return true; } if (part == RowPart::CLOSE) { // 关闭分组属于数据层操作,交由宿主窗口处理;但**抬起时才生效**, // 这里只记录目标(按住后移开再松开视为放弃)。 m_pendingClosePart = RowPart::CLOSE; m_pendingCloseRow = row; return true; } // 折叠 / 展开分组:三角、「组X」标签、**名称本体**、以及右侧的「N 项」计数都生效。 // 用户 2026-10-17 要求:名称本体恢复单击展开/收起(此前为了避开“误入改名” // 而禁掉了它,结果只能点三角、很不方便);误入改名改由**收紧双击阈值**解决 //(见 `OnDoubleClick`:只有快速双击才改名); // 右侧的「N 项」计数**只折叠展开**,任何频率的点击都不进入改名(用户 2026-10-17 要求)。 { // 只有名称区域(BODY)参与“快速双击进改名”的计时;三角 /「组X」/ 计数都不参与, // 否则“点一次计数、紧接着点一次名称”会被误判成双击。 if (part == RowPart::BODY) { m_groupNameClickMs = GetTickCount64(); m_groupNameClickRow = row; m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex); } } ToggleGroupFromRow(item.groupIndex, row); return true; } const SignalRef ref{item.groupIndex, item.signalIndex}; // ✕:用户明确表示不再需要该信号 → 交由宿主从数据层移除并释放其采样内存。 // 必须在 SelectSignal 之前处理,避免选中一条即将被删除的信号; // 且与其它 ✕ 一致,**抬起时才真正删除**(按住后移开再松开视为放弃)。 if (part == RowPart::REMOVE_SIGNAL) { m_pendingClosePart = RowPart::REMOVE_SIGNAL; m_pendingCloseRow = row; return true; } SelectSignal(ref); if (part == RowPart::CHECK) { // 勾选/取消勾选会立即触发网格重新划分(取消后空网格随之消失)。 m_tab->ToggleVisible(ref); m_rowsDirty = true; } else if (part == RowPart::LANE_BADGE) { // 在左键抬起时弹出,保证菜单不会随着按键松开而消失。 m_pendingLaneMenu = true; m_pendingLaneRef = ref; } else { // 点击信号名(行主体):除了选中它,还要让绘图区的**目标网格**完整出现在可视区内 //(上百个独立网格时,旧有高亮根本无法定位)。悬停不触发这一步。 RequestPlotLaneScroll(ref); } return true; } bool SignalPanel::OnLButtonUp(int x, int y) { bool changed = false; // ---- 关闭类按钮(✕):按下时记录目标,抬起时若指针仍在同一个 ✕ 上才执行 ---- if (m_pendingFilterClear) { m_pendingFilterClear = false; const float fx = static_cast(x); const float fy = static_cast(y); const gd::RectF clearRect = FilterClearRect(); if (clearRect.Width > 0.0f && UiPointInRect(clearRect, fx, fy)) { ClearFilter(); changed = true; } } if (m_pendingClosePart != RowPart::NONE) { const RowPart pendingPart = m_pendingClosePart; const int pendingRow = m_pendingCloseRow; m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row == pendingRow && part == pendingPart && row >= 0) { const ListRow& item = m_rows[static_cast(row)]; if (m_host != nullptr) { if (pendingPart == RowPart::CLOSE) { PostMessageW(m_host, WM_APP + 1, static_cast(item.groupIndex), 0); } else { // wParam = 分组下标,lParam = 组内信号下标(见 Application 的消息处理)。 PostMessageW(m_host, WM_APP + 2, static_cast(item.groupIndex), static_cast(item.signalIndex)); } } changed = true; } } if (m_draggingOpacity) { m_draggingOpacity = false; changed = true; } if (m_draggingWidth) { m_draggingWidth = false; changed = true; } if (m_draggingScrollBar) { m_draggingScrollBar = false; changed = true; } if (m_pendingColorPicker) { m_pendingColorPicker = false; const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_customChipRect, fx, fy) && m_tab != nullptr) { if (const SignalStyle* style = StyleOf(SelectedRef())) { changed = OpenColorPickerPopup(*style) || changed; } } } // 「系数」/「镜像」:按下时只登记,抬起时指针仍在同一按钮上才展开二级面板 //(与自定义颜色入口一致:按住后移开再松开视为放弃)。 if (m_pendingGainMenu) { m_pendingGainMenu = false; const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) && m_tab != nullptr) { changed = OpenGainMenu() || changed; } } if (m_pendingMirrorMenu) { m_pendingMirrorMenu = false; const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) && m_tab != nullptr) { changed = OpenMirrorMenu() || changed; } } if (m_pendingLaneMenu) { m_pendingLaneMenu = false; RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row >= 0 && part == RowPart::LANE_BADGE) { const ListRow& item = m_rows[static_cast(row)]; const SignalRef ref{item.groupIndex, item.signalIndex}; if (ref == m_pendingLaneRef) { const gd::RectF badge = SignalBadgeRect(row); OpenLaneMenu(ref, static_cast(badge.X), static_cast(badge.Y + badge.Height)); changed = true; } } } // 「目标」行的 GX 标签:与信号行的网格徽标同一套交互(抬起且仍在同一徽标上才展开)。 if (m_pendingTargetLaneMenu) { m_pendingTargetLaneMenu = false; const float fx = static_cast(x); const float fy = static_cast(y); UpdateTargetRowGeometry(); if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy) && m_tab != nullptr && SelectedRef().IsValid()) { OpenLaneMenu(SelectedRef(), static_cast(m_targetBadgeRect.X), static_cast(m_targetBadgeRect.Y + m_targetBadgeRect.Height)); changed = true; } } return changed; } bool SignalPanel::OnDoubleClick(int x, int y) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } const float fx = static_cast(x); const float fy = static_cast(y); // ⚠️⚠️ **编辑态下名称区域的双击必须被吃掉**: // 窗口带 `CS_DBLCLKS`,编辑时快速再点一下会被合成为 `WM_LBUTTONDBLCLK`, // 而旧实现直接进入行双击逻辑 ⇒ 分组被展开/收起(或再次进入改名)。 // 输入框内的双击本该是“选词”,本组件只提供整段选择,因此这里保持现状不变、 // 仅保证**不把双击当成行双击**(用户 2026-10-19 报告)。 if (m_editField != StyleEditField::NONE) { const gd::RectF field = EditFieldRect(); if (field.Width > 0.0f && (UiPointInRect(field, fx, fy) || (m_editRow >= 0 && m_editRow < static_cast(m_rows.size()) && UiPointInRect(NameEditAbsorbRect(field), fx, fy)))) { return false; } } // 样式区数值框:双击进入(或保持)输入状态。 if (UiPointInRect(m_widthValueRect, fx, fy)) { return BeginEdit(StyleEditField::WIDTH); } if (UiPointInRect(m_opacityValueRect, fx, fy)) { return BeginEdit(StyleEditField::OPACITY); } // 样式区顶部「目标」行:双击信号名(或它后面的 GX 标签)进入**改名**编辑态。 // ⚠️ 单击不进入编辑(见 `OnLButtonDown`),必须双击 —— 用户明确要求。 if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr) { UpdateTargetRowGeometry(); const bool onName = m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy); const bool onBadge = m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy); if (onName || onBadge) { // `row < 0` 表示编辑目标是"当前选中信号"(即目标行显示的信号)。 m_rowsDirty = true; return BeginEdit(StyleEditField::SIGNAL_NAME, -1); } } RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row < 0) { return false; } const ListRow item = m_rows[static_cast(row)]; // 分组行:双击折叠三角用于折叠;双击**名称区域**进入重命名 —— // ⚠️ 但只有**快速双击**才算(用户 2026-10-17 要求降低灵敏度): // 按下那一次已经把分组展开/收起了(`OnLButtonDown` 统一处理三角/「组X」/名称), // 因此这里要把它**撤销**回点击前的状态,然后才进入改名; // 超过 `kGroupRenameDoubleClickMs` 的“慢双击”只当**又一次单击**(再切一次折叠)。 if (item.kind == RowKind::GROUP) { if (part == RowPart::GROUP_COUNT) { // 「N 项」计数:**只折叠展开**,绝不进入改名(用户 2026-10-17 要求)。 // 按下那一 次已经切过折叠状态,这里再切一次 = 两次点击各自生效。 ToggleGroupFromRow(item.groupIndex, row); return true; } if (part == RowPart::DISCLOSURE) { // 与单击分组行同一入口(悬浮时先滚动就位再播放折起动画)。 ToggleGroupFromRow(item.groupIndex, row); return true; } if (part != RowPart::BODY) { return false; } // 名称区域:判定“快速双击”。 const std::uint64_t now = GetTickCount64(); const bool fastDoubleClick = (m_groupNameClickRow == row) && (now >= m_groupNameClickMs) && (now - m_groupNameClickMs <= kGroupRenameDoubleClickMs); if (!fastDoubleClick) { // 慢双击 / 两次无关点击:只当另一次单击(切换折叠)。 m_groupNameClickMs = now; m_groupNameClickRow = row; m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex); ToggleGroupFromRow(item.groupIndex, row); return true; } // 快速双击 ⇒ 改名:先撤销“按下”那一次对折叠状态的切换,再进入编辑。 m_groupNameClickMs = 0; m_groupNameClickRow = -1; SetGroupCollapsed(item.groupIndex, m_groupNameClickWasCollapsed); // ⚠️ **必须同时取消这个分组正在播放的折起/展开动画**:第一次单击已启动 // 150–320ms 的收起动画(`m_groupAnims` 里还留着 `collapsing` 条目)。只回滚折叠 // 状态不清动画的话,改名期间该组仍在播放“收起再瞬间弹回”,输入框画在正在平移的行上。 for (std::size_t index = 0; index < m_groupAnims.size();) { if (m_groupAnims[index].groupIndex == item.groupIndex) { m_groupAnims.erase(m_groupAnims.begin() + static_cast(index)); } else { ++index; } } m_rowsDirty = true; EnsureRows(); return BeginEdit(StyleEditField::GROUP_TITLE, GroupRowIndex(item.groupIndex)); } // 复选框、网格徽标与「移除信号」✕ 各自由单击处理,双击不再切换可见性/进入重命名。 if (part == RowPart::CHECK || part == RowPart::LANE_BADGE || part == RowPart::REMOVE_SIGNAL) { return false; } const SignalRef ref{item.groupIndex, item.signalIndex}; SelectSignal(ref); m_rowsDirty = true; return BeginEdit(StyleEditField::SIGNAL_NAME, row); } bool SignalPanel::DoubleClickIsPlainPress(int x, int y) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } const float fx = static_cast(x); const float fy = static_cast(y); // 真正有双击语义的位置(保持双击行为): // * 线宽 / 不透明度数值框:双击进入(或保持)输入状态; // * 样式区「目标」行的名称与 GX 标签:双击进入改名; // * 内联输入框自身:双击用于选词 / 定位插入符。 if (m_editField != StyleEditField::NONE && UiPointInRect(EditFieldRect(), fx, fy)) { return false; } if (UiPointInRect(m_widthValueRect, fx, fy) || UiPointInRect(m_opacityValueRect, fx, fy)) { return false; } UpdateTargetRowGeometry(); if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) { return false; } if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)) { return false; } RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row < 0) { // 列表之外(样式区按钮 / 筛选框 / 面板空白):全部只有单击语义。 return true; } // 行的**名称区域**(分组 / 信号)都靠双击改名 ⇒ 保留双击语义。 if (part == RowPart::BODY) { return false; } // 其余全部(复选框、折叠三角、「组X」、✕、GX 徽标、全部显示隐藏、色块、绘制方式、 // 滑块、「调整」按钮、筛选框…)都只有单击语义 ⇒ 快速连点必须当作一次次按下。 return true; } bool SignalPanel::OnRButtonDown(int x, int y) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row < 0) { return false; } const ListRow& item = m_rows[static_cast(row)]; if (item.kind == RowKind::GROUP) { OpenGroupMenu(item.groupIndex, x, y); return true; } const SignalRef ref{item.groupIndex, item.signalIndex}; SelectSignal(ref); OpenSignalMenu(ref, x, y); return true; } bool SignalPanel::OnMouseWheel(int delta) { // 用户主动滚动 ⇒ 立即取消正在进行的目标信号滚动动画(否则两者会互相打架)。 CancelScrollAnimation(); if (m_dataSet == nullptr) { return false; } const float previous = m_scrollOffset; m_scrollOffset -= static_cast(delta) / 120.0f * 54.0f * m_scale; ClampScroll(); return std::fabs(previous - m_scrollOffset) > 0.01f; } bool SignalPanel::OnMouseLeave() { const bool changed = (m_hoverRow >= 0) || (m_hoverStyle != StyleHit::NONE) || m_hoveredSignal.IsValid(); m_hoverRow = -1; m_hoverPart = RowPart::NONE; m_hoverStyle = StyleHit::NONE; m_hoverStyleIndex = -1; // 鼠标离开面板 → 取消绘图区的联动高亮(用户要求"移开鼠标时恢复")。 m_hoveredSignal = SignalRef(); return changed; } void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y) { if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } const SignalStyle* style = StyleOf(ref); const SignalData* signal = m_dataSet->Resolve(ref); if (style == nullptr || signal == nullptr) { return; } m_menuTargetSignal = ref; m_menuTargetGroup = -1; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = signal->name; items.push_back(header); MenuItem toggle; toggle.label = m_tab->IsVisible(ref) ? L"隐藏该信号" : L"显示该信号"; toggle.id = CMD_SIGNAL_TOGGLE; items.push_back(toggle); MenuItem solo; solo.label = L"仅显示该信号"; solo.id = CMD_SIGNAL_SOLO; items.push_back(solo); MenuItem showAll; showAll.label = L"显示全部信号"; showAll.id = CMD_SIGNAL_SHOW_ALL; items.push_back(showAll); MenuItem hideAll; hideAll.label = L"隐藏全部信号"; hideAll.id = CMD_SIGNAL_HIDE_ALL; items.push_back(hideAll); MenuItem separator; separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); MenuItem colors; colors.kind = MenuItemKind::SWATCHES; colors.id = CMD_COLOR_BASE; colors.swatchCount = std::min(paletteCount, 16); colors.checkedSwatch = -1; for (int index = 0; index < colors.swatchCount; ++index) { colors.swatches[index] = palette[index]; if (style->color == palette[index]) { colors.checkedSwatch = index; } } items.push_back(colors); MenuItem modeHeader; modeHeader.kind = MenuItemKind::HEADER; modeHeader.label = L"样式"; items.push_back(modeHeader); for (int index = 0; index < kModeOptionCount; ++index) { MenuItem mode; mode.label = DrawModeName(kModeOptions[index]); mode.id = CMD_MODE_BASE + index; mode.checked = (style->mode == kModeOptions[index]); items.push_back(mode); } const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } void SignalPanel::OpenGroupMenu(int groupIndex, int x, int y) { if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } const SignalGroup* group = m_dataSet->GroupAt(groupIndex); if (group == nullptr) { return; } m_menuTargetGroup = groupIndex; m_menuTargetSignal = SignalRef(); std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = group->name; items.push_back(header); MenuItem showAll; showAll.label = L"选中该分组全部信号"; showAll.id = CMD_GROUP_SHOW_ALL; items.push_back(showAll); MenuItem hideAll; hideAll.label = L"取消该分组全部信号"; hideAll.id = CMD_GROUP_HIDE_ALL; items.push_back(hideAll); MenuItem separator; separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); MenuItem remove; remove.label = L"关闭该分组(从工作区移除)"; remove.id = CMD_GROUP_REMOVE; items.push_back(remove); const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y) { if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } const SignalData* signal = m_dataSet->Resolve(ref); if (signal == nullptr) { return; } m_menuTargetSignal = ref; m_menuTargetGroup = -1; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = L"所属网格"; items.push_back(header); const std::vector& lanes = m_tab->Lanes(); const int currentLane = m_tab->LaneOfSignal(ref); for (std::size_t index = 0; index < lanes.size(); ++index) { MenuItem lane; lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" + std::to_wstring(lanes[index].signals.size()) + L" 条信号)"; lane.id = CMD_LANE_BASE + static_cast(index); lane.checked = (currentLane == static_cast(index)); items.push_back(lane); } MenuItem separator; separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); MenuItem create; create.label = L"新建网格"; create.id = CMD_LANE_NEW; items.push_back(create); const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; // ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格): // 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。 // 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求): // 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的 // 「新建网格」**始终固定不动**。 MenuLayoutConstraint constraint; constraint.maxScrollRows = kLaneMenuVisibleRows; // 额外加一道硬上限:窗口很矮时也不得超出客户区(与行数上限取更严格者)。 constraint.maxHeight = std::max(180.0f, bounds.Height * 0.8f); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint); } bool SignalPanel::OnMenuCommand(const MenuResult& result) { if (m_tab == nullptr || m_dataSet == nullptr || !result.valid) { return false; } // 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开 //(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。 if (result.editCommit) { SignalStyle* style = StyleOf(SelectedRef()); if (style != nullptr) { const std::wstring trimmed = Trim(result.editText); wchar_t* stop = nullptr; const double parsed = trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop); // 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。 const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed); switch (result.id) { case CMD_GAIN_EDIT: { if (valid) { style->gain = static_cast(std::max(-kGainLimit, std::min(parsed, kGainLimit))); } if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatNumberText(static_cast(style->gain))); } return false; } case CMD_OFFSET_Y: { if (valid) { style->offsetY = static_cast( std::max(-static_cast(kGainLimit), std::min(parsed, kGainLimit))); } if (m_menu != nullptr) { m_menu->UpdateItemValue( CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), FormatNumberText(static_cast(style->offsetY))); } return false; } case CMD_OFFSET_TIME: { if (valid) { style->offsetTime = std::max(-kGainLimit, std::min(parsed, kGainLimit)); } if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(style->offsetTime), FormatNumberText(style->offsetTime)); } return false; } default: break; } } else { return false; } } // 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。 if (result.id == CMD_GAIN_RESET) { if (SignalStyle* style = StyleOf(SelectedRef())) { style->gain = 1.0f; if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatNumberText(static_cast(style->gain))); } } return false; } // 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。 if (result.id == CMD_OFFSET_RESET) { if (SignalStyle* style = StyleOf(SelectedRef())) { style->offsetY = 0.0f; style->offsetTime = 0.0; if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), FormatNumberText(0.0)); m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0), FormatNumberText(0.0)); } } return false; } // 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。 if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL) { if (SignalStyle* style = StyleOf(SelectedRef())) { const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL); bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical; flag = !flag; if (!horizontal) { // ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量, // 使绘制等价于「先加上偏移再镜像」(用户明确要求)。 style->offsetY = -style->offsetY; if (m_menu != nullptr) { m_menu->UpdateItemValue( CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), FormatNumberText(static_cast(style->offsetY))); } } if (m_menu != nullptr) { m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true); } } return false; } bool needRelayout = false; switch (result.id) { case CMD_SIGNAL_TOGGLE: if (m_menuTargetSignal.IsValid()) { m_tab->ToggleVisible(m_menuTargetSignal); m_rowsDirty = true; needRelayout = true; } break; case CMD_SIGNAL_SOLO: if (m_menuTargetSignal.IsValid()) { m_tab->SoloSignal(m_menuTargetSignal); m_rowsDirty = true; needRelayout = true; } break; case CMD_SIGNAL_SHOW_ALL: m_tab->SetAllVisible(true); m_rowsDirty = true; needRelayout = true; break; case CMD_SIGNAL_HIDE_ALL: m_tab->SetAllVisible(false); m_rowsDirty = true; needRelayout = true; break; case CMD_GROUP_SHOW_ALL: if (m_menuTargetGroup >= 0) { m_tab->SetGroupVisible(m_menuTargetGroup, true); m_rowsDirty = true; needRelayout = true; } break; case CMD_GROUP_HIDE_ALL: if (m_menuTargetGroup >= 0) { m_tab->SetGroupVisible(m_menuTargetGroup, false); m_rowsDirty = true; needRelayout = true; } break; case CMD_GROUP_REMOVE: if (m_menuTargetGroup >= 0 && m_host != nullptr) { PostMessageW(m_host, WM_APP + 1, static_cast(m_menuTargetGroup), 0); } break; case CMD_LANE_NEW: if (m_menuTargetSignal.IsValid()) { m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1); m_rowsDirty = true; needRelayout = true; } break; default: if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW) { if (m_menuTargetSignal.IsValid()) { m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE); m_rowsDirty = true; needRelayout = true; } } else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0) { if (SignalStyle* style = StyleOf(m_menuTargetSignal)) { int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); style->color = palette[result.swatchIndex % std::max(1, paletteCount)]; } } else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount) { if (SignalStyle* style = StyleOf(m_menuTargetSignal)) { style->mode = kModeOptions[result.id - CMD_MODE_BASE]; } } break; } return needRelayout; } } // namespace jdi