// Application 实现分片 —— **菜单**:绘图区右键菜单(含「移动到…」子菜单)、 // 菜单数值回写、主题切换、设置菜单,以及菜单结果的统一派发。 // // 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 // 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出, // **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11。 #include "ApplicationInternal.hpp" namespace jdi { void Application::ShowPlotContextMenu(int laneIndex, int x, int y) { if (laneIndex < 0) { return; } m_plotMenuLane = laneIndex; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = L"网格 " + std::to_wstring(laneIndex + 1); items.push_back(header); // 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。 double laneYMin = 0.0; double laneYMax = 1.0; m_plot.LaneRange(laneIndex, laneYMin, laneYMax); MenuItem maxItem; maxItem.kind = MenuItemKind::EDIT; maxItem.label = L"Y 轴最大值"; maxItem.id = PLOT_CMD_LANE_Y_MAX; maxItem.value = FormatValue(laneYMax); maxItem.filter = EditFilter::NUMBER; items.push_back(maxItem); MenuItem minItem; minItem.kind = MenuItemKind::EDIT; minItem.label = L"Y 轴最小值"; minItem.id = PLOT_CMD_LANE_Y_MIN; minItem.value = FormatValue(laneYMin); minItem.filter = EditFilter::NUMBER; items.push_back(minItem); // 选区自适应(切换项):勾选后每次本网格的时间轴选区放大都会自动匹配 Y 轴幅值。 MenuItem autoViewItem; autoViewItem.label = L"选区 Y 轴自适应"; autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW; autoViewItem.checked = m_plot.LaneAutoFitToSelection(laneIndex); items.push_back(autoViewItem); // 时间轴自适应到本网格信号范围(原先位于分隔线下方,现移到分隔线上方)。 MenuItem fitLaneTime; fitLaneTime.label = L"按网格时间轴展开"; fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME; items.push_back(fitLaneTime); // 「拆分当前网格」:把该网格内的信号按个拆成独立网格,插到其下方依次排列。 // 可点击时在后方追加「(N个信号)」说明将拆出的网格数;只有 1 条信号时置灰且不加尾缀。 MenuItem splitLane; const std::size_t laneSignalCount = m_plot.LaneSignalCount(laneIndex); splitLane.label = (laneSignalCount > 1) ? (L"拆分当前网格(" + std::to_wstring(laneSignalCount) + L"个信号)") : L"拆分当前网格"; splitLane.id = PLOT_CMD_LANE_SPLIT; splitLane.enabled = laneSignalCount > 1; items.push_back(splitLane); // 实验性功能(二级菜单):横线上方,鼠标悬停即向右展开。 MenuItem experimental; experimental.kind = MenuItemKind::SUBMENU; experimental.label = L"实验性功能"; { MenuItem edgeCount; edgeCount.label = L"边沿计数"; edgeCount.id = PLOT_CMD_LANE_EDGE_COUNT; edgeCount.checked = m_plot.LaneEdgeCount(laneIndex); experimental.submenu.push_back(edgeCount); MenuItem edgeCenter; edgeCenter.label = L"边沿计数(中心轴)"; edgeCenter.id = PLOT_CMD_LANE_EDGE_CENTER; edgeCenter.checked = m_plot.LaneEdgeCenterCount(laneIndex); experimental.submenu.push_back(edgeCenter); MenuItem meanFilter; meanFilter.label = L"均值滤波"; meanFilter.id = PLOT_CMD_LANE_MEAN_FILTER; meanFilter.checked = m_plot.LaneMeanFilter(laneIndex); experimental.submenu.push_back(meanFilter); MenuItem weakPeak; weakPeak.label = L"弱峰调整"; weakPeak.id = PLOT_CMD_LANE_WEAK_PEAK; weakPeak.checked = m_plot.LaneWeakPeak(laneIndex); experimental.submenu.push_back(weakPeak); MenuItem freeMarker; freeMarker.label = L"自由标记"; freeMarker.id = PLOT_CMD_LANE_FREE_MARKER; freeMarker.checked = m_plot.LaneFreeMarker(laneIndex); // 「重采样」位于「自由标记」**上方**(用户 2026-11-02 要求)。 MenuItem resample; resample.label = L"重采样"; resample.id = PLOT_CMD_LANE_RESAMPLE; resample.checked = m_plot.LaneResample(laneIndex); experimental.submenu.push_back(resample); // 「选区截取」位于「重采样」下方、「自由标记」上方(用户 2026-11-03 要求)。 MenuItem clip; clip.label = L"选区截取"; clip.id = PLOT_CMD_LANE_CLIP; clip.checked = m_plot.LaneClip(laneIndex); experimental.submenu.push_back(clip); experimental.submenu.push_back(freeMarker); } items.push_back(experimental); MenuItem separator; separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); // 全局开关:一次性打开/关闭全部网格各自的「选区 Y 轴自适应」。 MenuItem autoFitAllItem; autoFitAllItem.label = L"选区 Y 轴自适应(全局)"; autoFitAllItem.id = PLOT_CMD_AUTO_FIT_ALL; autoFitAllItem.checked = m_plot.AllLanesAutoFitToSelection(); items.push_back(autoFitAllItem); MenuItem fitItem; fitItem.label = L"全部自适应"; fitItem.id = PLOT_CMD_FIT_ALL; items.push_back(fitItem); RECT client = {}; GetClientRect(m_hwnd, &client); const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), static_cast(client.bottom - client.top)); m_menu.Open(MenuScope::PLOT_VIEW, bounds, x, y, items); } void Application::RefreshPlotMenuValues(int laneIndex) { if (laneIndex < 0) { return; } double laneYMin = 0.0; double laneYMax = 1.0; m_plot.LaneRange(laneIndex, laneYMin, laneYMax); m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MAX, FormatValue(laneYMax)); m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MIN, FormatValue(laneYMin)); // 「选区 Y 轴自适应」(本网格)与「(全局)」都是切换项,实时反映各自的勾选状态。 m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO_VIEW, L"选区 Y 轴自适应", m_plot.LaneAutoFitToSelection(laneIndex), true); m_menu.UpdateItemLabel(PLOT_CMD_AUTO_FIT_ALL, L"选区 Y 轴自适应(全局)", m_plot.AllLanesAutoFitToSelection(), true); } void Application::ApplyTheme(ThemeKind kind) { if (kind == m_themeKind && m_theme.windowBg == ThemeFor(kind).windowBg) { return; // 幂等:同一个主题重复点击不必作废缓存 } m_themeKind = kind; m_theme = ThemeFor(kind); SetCurrentTheme(&m_theme); // 主题色烘进了多处位图缓存,必须整体作废(只作废场景缓存是不够的: // 宽基底图层、装饰层、浮动面板层、信号列表行位图里都存着旧配色的像素)。 m_plot.OnThemeChanged(); m_panel.OnThemeChanged(); // 装饰区(标签栏 / 工具栏 / 面板)全部重画,并整窗回送。 m_chromeDirty = true; m_panelDirty = true; InvalidateRect(m_hwnd, nullptr, TRUE); } void Application::OpenSettingsMenu() { // 锚点取齿轮按钮的**实际矩形**:按钮宽度由控件实测,宿主不应自己推算几何。 gd::RectF anchor; if (!m_toolbar.ButtonRect(ToolbarAction::SETTINGS, anchor)) { // 尚未布局(极端情形):退化为从菜单快捷键位置展开,而不是什么都不发生。 anchor = m_toolbarRect; } std::vector items; // ── 调试 ────────────────────────────────────────────── MenuItem debugHeader; debugHeader.kind = MenuItemKind::HEADER; debugHeader.label = L"调试"; items.push_back(debugHeader); MenuItem boundsItem; boundsItem.label = L"显示布局边界"; boundsItem.id = kAppCmdShowLayoutBounds; boundsItem.checked = m_showLayoutBounds; // 切换项:点击后菜单保持打开,勾选态由宿主回写(与既有「镜像」等切换项一致)。 boundsItem.keepOpen = true; items.push_back(boundsItem); // ── 主题配色(二级菜单:鼠标悬停展开,带勾选态)────────────── // 顺序固定为 Light / Solarised Light / Dark / Solarised Dark(用户 2026-10-29 指定)。 // `keepOpen` 让点击主题后菜单不关闭,勾选态由宿主回写 —— 便于连续预览几种配色。 MenuItem themeItem; themeItem.kind = MenuItemKind::SUBMENU; themeItem.label = L"主题配色"; for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) { const ThemeKind kind = static_cast(index); MenuItem option; option.kind = MenuItemKind::NORMAL; option.label = ThemeName(kind); option.id = kAppCmdThemeBase + index; option.checked = (m_themeKind == kind); option.keepOpen = true; themeItem.submenu.push_back(option); } items.push_back(themeItem); RECT client = {}; GetClientRect(m_hwnd, &client); const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), static_cast(client.bottom - client.top)); // 右下展开,锚在按钮**下沿**(留 4 逻辑像素,视觉上不贴着按钮边框)。 const int x = static_cast(anchor.X); const int y = static_cast(anchor.Y + anchor.Height + 4.0f * m_scale); m_menu.Open(MenuScope::APP_SETTINGS, bounds, x, y, items); } void Application::DispatchMenuResult(const MenuResult& result) { if (!result.valid) { return; } switch (result.scope) { case MenuScope::APP_SETTINGS: { // 应用设置菜单:全部条目都是**带勾选态的切换项**,菜单保持打开, // 勾选变化通过 `UpdateItemLabel` 立即回写(连续切换多项时不用反复打开菜单)。 if (result.id == kAppCmdShowLayoutBounds) { m_showLayoutBounds = !m_showLayoutBounds; m_menu.UpdateItemLabel(kAppCmdShowLayoutBounds, L"显示布局边界", m_showLayoutBounds); // 边界比组件矩形外扩 1px 且跨越所有装饰区 ⇒ 必须整帧重绘。 m_chromeDirty = true; } else if (result.id >= kAppCmdThemeBase && result.id <= kAppCmdThemeBase + static_cast(ThemeKind::SOLARIZED_DARK)) { const ThemeKind kind = static_cast(result.id - kAppCmdThemeBase); ApplyTheme(kind); // 回写勾选态:四项里只有当前主题打勾(子菜单项要用专用接口才会重建布局)。 for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) { const ThemeKind option = static_cast(index); m_menu.UpdateSubmenuItemLabel(kAppCmdThemeBase + index, ThemeName(option), m_themeKind == option); } } InvalidateRect(m_hwnd, nullptr, TRUE); break; } case MenuScope::SIGNAL_PANEL: { const bool needRelayout = m_panel.OnMenuCommand(result); // 「公式映射」等就地输入可能产生**非模态**警告(不使用 Windows 消息框), // 由绘图区底部的自绘提示浮层显示(与加载失败提示同一套)。 { std::wstring hint; if (m_panel.TakePendingHint(hint)) { m_plot.ShowPersistentHint(hint); } } if (needRelayout) { Relayout(); } InvalidateRect(m_hwnd, nullptr, FALSE); break; } case MenuScope::PLOT_VIEW: { // 菜单内联输入框的提交:就地更新网格 Y 轴上下限,菜单保持打开, // 并把夹取后的真实值回写到菜单上(输入值可能被合法区间修正)。 if (result.editCommit) { const int laneIndex = m_plotMenuLane; if (laneIndex >= 0) { m_plot.ApplyLaneValueText(laneIndex, result.id == PLOT_CMD_LANE_Y_MAX, result.editText); RefreshPlotMenuValues(laneIndex); } SetFocus(m_hwnd); InvalidateRect(m_hwnd, nullptr, FALSE); break; } const int laneIndex = m_plotMenuLane; m_plotMenuLane = -1; switch (result.id) { case PLOT_CMD_LANE_AUTO_VIEW: // 切换本网格的「选区 Y 轴自适应」。 m_plot.ToggleLaneAutoFitToSelection(laneIndex); break; case PLOT_CMD_AUTO_FIT_ALL: { // 全局切换:一次打开/关闭全部网格各自的开关;打开时立即生效一次。 const bool enable = !m_plot.AllLanesAutoFitToSelection(); m_plot.SetAllLanesAutoFitToSelection(enable, true); break; } case PLOT_CMD_LANE_FIT_TIME: // 「按网格时间轴展开」:展开时间轴后同时把该网格的 Y 轴自适应一次。 m_plot.FitTimeToLane(laneIndex); break; case PLOT_CMD_LANE_EDGE_COUNT: // 「实验性功能 → 边沿计数」:切换本网格的横向光标与统计面板。 m_plot.ToggleLaneEdgeCount(laneIndex); break; case PLOT_CMD_LANE_EDGE_CENTER: // 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。 m_plot.ToggleLaneEdgeCenterCount(laneIndex); break; case PLOT_CMD_LANE_MEAN_FILTER: // 「实验性功能 → 均值滤波」:切换均值滤波曲线与参数面板。 m_plot.ToggleLaneMeanFilter(laneIndex); break; case PLOT_CMD_LANE_WEAK_PEAK: // 「实验性功能 → 弱峰调整」:切换弱峰调整曲线与参数面板。 m_plot.ToggleLaneWeakPeak(laneIndex); break; case PLOT_CMD_LANE_FREE_MARKER: // 「实验性功能 → 自由标记」:切换网格内跟随鼠标的纵向光标与固定数据点面板。 m_plot.ToggleLaneFreeMarker(laneIndex); break; case PLOT_CMD_LANE_RESAMPLE: // 「实验性功能 → 重采样」:切换重采样参数面板。 m_plot.ToggleLaneResample(laneIndex); break; case PLOT_CMD_LANE_CLIP: // 「实验性功能 → 选区截取」:切换选区截取面板。 m_plot.ToggleLaneClip(laneIndex); break; case PLOT_CMD_LANE_SPLIT: // 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。 m_plot.SplitLane(laneIndex); break; case PLOT_CMD_FIT_ALL: m_plot.FitAll(); break; default: break; } SetFocus(m_hwnd); InvalidateRect(m_hwnd, nullptr, TRUE); break; } case MenuScope::TAB_BAR: { const int index = m_tabMenuIndex; if (result.id == kMenuTabNew) { NewTab(true); } else if (result.id == kMenuTabClose) { CloseTab(index); } else if (result.id == kMenuTabCloseOthers) { if (index >= 0 && index < static_cast(m_tabs.size()) && m_tabs.size() > 1) { const TabConfig keep = m_tabs[static_cast(index)]; m_tabs.clear(); m_tabs.push_back(keep); SetActiveTab(0); } } m_tabMenuIndex = -1; InvalidateRect(m_hwnd, nullptr, TRUE); break; } case MenuScope::NONE: default: break; } } } // namespace jdi