// SignalPanel 实现分片 —— 弹出菜单(系数/偏移、镜像、信号/分组/网格右键菜单、命令回执)。 // // 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。 // ⚠️ 菜单是"打开即模态"的浮层:`CloseTransientUi` 必须在点击别处时收起它, // 否则菜单会一直挂在那里吃掉后续点击。 #include "SignalPanelInternal.hpp" namespace jdi { 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 = PanelBounds(); 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 = PanelBounds(); // 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。 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; } 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; } (void)style; // 菜单已不再需要外观:颜色 / 线宽 / 透明度 / 绘制样式都在下方样式区编辑。 m_menuTargetSignal = ref; m_menuTargetGroup = -1; // ⚠️ 信号右键菜单**刻意保持极简**(用户 2026-10-29 要求): // 颜色、绘制样式、可见性、显示全部/隐藏全部这些原来放在这里的条目,现在 // 都已被下方**样式自定义区**与分组右键菜单覆盖,重复的入口只会让菜单变长、变乱。 // 现在只保留两件事: // * 「克隆该信号」(在「仅显示该信号」**之上**); // * 「仅显示该信号」。 std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = signal->name; items.push_back(header); MenuItem clone; clone.label = L"克隆该信号"; clone.id = CMD_SIGNAL_CLONE; items.push_back(clone); MenuItem solo; solo.label = L"仅显示该信号"; solo.id = CMD_SIGNAL_SOLO; items.push_back(solo); const gd::RectF bounds = PanelBounds(); 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 = PanelBounds(); 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 = PanelBounds(); // ⚠️ 网格可能是**上百个**(例如把 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_CLONE: // 「克隆该信号」:把原信号的「调整」(除时间轴偏移)烘焙进采样值后插入下方, // 具体构建在宿主里完成(需要访问 DataSet 与全部标签页)。 if (m_menuTargetSignal.IsValid() && m_host != nullptr) { PostMessageW(m_host, WM_APP + 5, static_cast(m_menuTargetSignal.groupIndex), static_cast(m_menuTargetSignal.signalIndex)); } break; case CMD_SIGNAL_SOLO: if (m_menuTargetSignal.IsValid()) { m_tab->SoloSignal(m_menuTargetSignal); 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; } } break; } return needRelayout; } } // namespace jdi