// Application 实现分片 —— 本文件保留:**数据命令**(分组/信号增删、移动与重排)、 // 文件对话框、工具栏动作、键盘与鼠标分发、布局边界调试绘制。 // // 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 // 本文件原为 2,165 行的“大杂烩”,2026-11-04 代码审查按职责拆出: // 标签页 → `ApplicationTabs.cpp`;派生/重采样/截取/克隆 → `ApplicationDerive.cpp`; // 上下文菜单与设置菜单 → `ApplicationMenus.cpp`。划分见 `Code_Review_Plan.md` 的 P11。 #include "ApplicationInternal.hpp" #include "UiDebugBounds.hpp" namespace jdi { void Application::RemoveGroup(int groupIndex) { const SignalGroup* group = m_dataSet.GroupAt(groupIndex); if (group == nullptr) { return; } const std::wstring name = group->name; // 移除分组会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。 m_plot.PauseDerivedAsync(); // 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。 for (TabConfig& tab : m_tabs) { tab.RemapAfterGroupRemoval(groupIndex); } m_dataSet.RemapSelectionAfterGroupRemoval(groupIndex); m_dataSet.RemoveGroup(groupIndex); SyncTabsToDataSet(); SetStatusMessage(L"已从工作区移除:" + name, false); UpdateWindowTitle(); } void Application::RemoveSignal(int groupIndex, int signalIndex) { SignalRef target; target.groupIndex = groupIndex; target.signalIndex = signalIndex; const SignalData* signal = m_dataSet.Resolve(target); const SignalGroup* group = m_dataSet.GroupAt(groupIndex); if (signal == nullptr || group == nullptr) { return; } const std::wstring name = signal->name; if (group->signals.size() <= 1) { // 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。 RemoveGroup(groupIndex); return; } // 移除信号会释放采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。 m_plot.PauseDerivedAsync(); // 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。 for (TabConfig& tab : m_tabs) { tab.RemapAfterSignalRemoval(groupIndex, signalIndex); } m_dataSet.RemapSelectionAfterSignalRemoval(groupIndex, signalIndex); m_dataSet.RemoveSignal(groupIndex, signalIndex); SyncTabsToDataSet(); SetStatusMessage(L"已从工作区移除信号:" + name, false); } std::wstring Application::UniqueGroupName(const std::wstring& base) const { const auto taken = [this](const std::wstring& candidate) { for (const SignalGroup& group : m_dataSet.Groups()) { if (group.name == candidate) { return true; } } return false; }; if (!taken(base)) { return base; } for (int suffix = 2; suffix < 1000; ++suffix) { const std::wstring candidate = base + L" (" + FormatInteger(suffix) + L")"; if (!taken(candidate)) { return candidate; } } return base; } bool Application::MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex, const std::wstring* newGroupName) { const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup); if (sourceGroup == nullptr || fromSignal < 0 || static_cast(fromSignal) >= sourceGroup->signals.size()) { return false; } // 「移动到全新分组」:目标分组尚不存在,它会被追加到分组列表末尾 //(下标 = 当前分组数,可在此之前预先算出,供引用改写使用)。 const bool createGroup = (toGroup < 0) && (newGroupName != nullptr); const int resolvedTo = createGroup ? static_cast(m_dataSet.GroupCount()) : toGroup; const SignalGroup* targetGroup = createGroup ? nullptr : m_dataSet.GroupAt(resolvedTo); if (!createGroup && targetGroup == nullptr) { return false; } const bool sameGroup = !createGroup && (fromGroup == resolvedTo); // 目标下标:负数/越界表示追加到末尾(组内重排时"末尾"= 组内信号数-1,因为该信号会先被摘出)。 int position = toIndex; const int targetCount = createGroup ? 0 : static_cast(targetGroup->signals.size()); if (createGroup) { position = 0; } else if (sameGroup) { if (position < 0 || position >= targetCount) { position = targetCount - 1; } if (position == fromSignal) { return false; } } else if (position < 0 || position > targetCount) { position = targetCount; } SignalRef from; from.groupIndex = fromGroup; from.signalIndex = fromSignal; SignalRef to; to.groupIndex = resolvedTo; to.signalIndex = position; // 移动会搬运大量采样缓冲:先暂停后台派生曲线计算,避免其读取正在被移动的内存。 m_plot.PauseDerivedAsync(); // 先按**旧下标**修正各标签页内的全部信号引用,再动数据层。 // ⚠️ 跨组(含新建分组)时目标分组那一行可见性表可能还不存在, // `RemapAfterSignalMove` 内部会自行扩展 —— 这里**不能**先 `SyncToDataSet`, // 因为那次同步会按旧下标清理/修正引用,随后下标一变就会指向错误的信号。 if (sameGroup) { for (TabConfig& tab : m_tabs) { tab.RemapAfterSignalReorder(fromGroup, fromSignal, position); } } else { for (TabConfig& tab : m_tabs) { tab.RemapAfterSignalMove(from, to, tab.IsVisible(from)); } } // 共享选中项:被移动的正是当前选中信号时,改为指向新位置。 if (m_dataSet.Selected().groupIndex == fromGroup) { if (sameGroup) { // 与 `RemapAfterSignalReorder` 同一套线性映射。 int sel = m_dataSet.Selected().signalIndex; if (sel == fromSignal) { sel = position; } else if (fromSignal < position) { if (sel > fromSignal && sel <= position) { --sel; } } else if (sel >= position && sel < fromSignal) { ++sel; } SignalRef next; next.groupIndex = fromGroup; next.signalIndex = sel; m_dataSet.SetSelected(next); } else if (m_dataSet.Selected().signalIndex == fromSignal) { m_dataSet.SetSelected(to); } } int newSignalIndex = -1; int newGroupIndex = -1; if (createGroup) { // 建组 + 搬运做成一次原子操作(`AddGroup` 拒收空组,见 `MoveSignalToNewGroup`)。 if (!m_dataSet.MoveSignalToNewGroup(fromGroup, fromSignal, *newGroupName, newGroupIndex, newSignalIndex)) { // 前面已校验过,理论不可达;若真发生(数据层拒绝),必须把已改写的引用 // 按数据层的真实状态重新对齐,避免留下指向不存在分组的悬空引用。 SyncTabsToDataSet(); return false; } } else { // 参数已在此前校验过(分组与信号下标都合法),本调用不会失败。 m_dataSet.MoveSignal(fromGroup, fromSignal, resolvedTo, position, newSignalIndex); } SyncTabsToDataSet(); return true; } void Application::ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex) { (void)MoveSignalCore(fromGroup, fromSignal, toGroup, toIndex, nullptr); } void Application::MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup) { const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup); if (sourceGroup == nullptr || fromSignal < 0 || static_cast(fromSignal) >= sourceGroup->signals.size()) { return; } const std::wstring signalName = sourceGroup->signals[static_cast(fromSignal)].name; if (toGroup < 0) { // 「移动到… → 新建分组」:组名固定为「新建分组」(重名自动加序号)。 const std::wstring name = UniqueGroupName(L"新建分组"); if (MoveSignalCore(fromGroup, fromSignal, -1, 0, &name)) { SetStatusMessage(L"已将信号「" + signalName + L"」移动到新建分组:" + name, false); } return; } if (toGroup == fromGroup) { return; // 已经在该分组,无需移动 } if (!MoveSignalCore(fromGroup, fromSignal, toGroup, -1, nullptr)) { return; } const SignalGroup* destination = m_dataSet.GroupAt(toGroup); SetStatusMessage(L"已将信号「" + signalName + L"」移动到分组:" + (destination != nullptr ? destination->name : std::wstring()), false); } void Application::OpenFileDialog() { // 允许一次选择多个文件,便于跨文件对比信号。 std::vector buffer(static_cast(MAX_PATH) * 16, L'\0'); OPENFILENAMEW dialog = {}; dialog.lStructSize = sizeof(dialog); dialog.hwndOwner = m_hwnd; dialog.lpstrFilter = SignalFileDialogFilter(); dialog.lpstrFile = buffer.data(); dialog.nMaxFile = static_cast(buffer.size()); dialog.lpstrTitle = L"打开测量数据文件(可多选)"; dialog.lpstrDefExt = L"csv"; dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR | OFN_ALLOWMULTISELECT; if (GetOpenFileNameW(&dialog) == FALSE) { return; } // 多选时首段为目录,其余为文件名;单选时整段即为完整路径。 const std::wstring first(buffer.data()); const wchar_t* cursor = buffer.data() + first.size() + 1; int loaded = 0; if (*cursor == L'\0') { if (LoadFile(first)) { ++loaded; } } else { while (*cursor != L'\0') { const std::wstring name(cursor); if (LoadFile(first + L"\\" + name)) { ++loaded; } cursor += name.size() + 1; } } if (loaded > 0) { SetStatusMessage(L"已加载 " + FormatInteger(loaded) + L" 个文件,请在左侧勾选需要查看的信号。", false); } } void Application::ExecuteToolbarAction(ToolbarAction action) { switch (action) { case ToolbarAction::SETTINGS: // 齿轮按钮只负责**展开菜单**:不改变任何业务状态,因此不必走后面的"整帧失效", // 只需要把浮层画出来(菜单打开后 `Render` 会因 `overlaysVisible` 自动整帧重绘)。 OpenSettingsMenu(); if (m_hwnd != nullptr) { InvalidateRect(m_hwnd, nullptr, TRUE); } return; case ToolbarAction::OPEN_FILE: OpenFileDialog(); break; case ToolbarAction::FIT_ALL: // 自适应同时回到初始状态:退出缩放模式并显示全部信号范围。 m_plot.FitAll(); SetStatusMessage(L"已恢复自适应视图。", false); break; case ToolbarAction::AUTO_FIT_Y: { // 与网格右键菜单中的「选区 Y 轴自适应(全局)」同一开关: // 打开后立即按当前视图自适应一次,之后每次时间轴选区放大都会自动匹配。 const bool enable = !m_plot.AllLanesAutoFitToSelection(); m_plot.SetAllLanesAutoFitToSelection(enable, true); SetStatusMessage(enable ? L"已开启选区 Y 轴自适应:时间轴选区放大后自动匹配各网格 Y 轴。" : L"已关闭选区 Y 轴自适应。", false); break; } case ToolbarAction::ZOOM_SELECT: m_plot.ToggleZoomMode(PlotZoomMode::SELECT); break; case ToolbarAction::CURSOR: m_plot.CycleCursorMode(); break; case ToolbarAction::MERGE_LANES: if (!m_dataSet.Empty()) { ActiveTab().SetMode(LaneMode::MERGED); SetStatusMessage(L"已将当前标签页的全部可见信号合并到同一网格。", false); } break; case ToolbarAction::SPLIT_LANES: if (!m_dataSet.Empty()) { ActiveTab().SetMode(LaneMode::PER_SIGNAL); SetStatusMessage(L"当前标签页已切换为「每信号一格」。", false); } break; case ToolbarAction::CLEAR_ALL: if (!m_dataSet.Empty()) { ActiveTab().SetAllVisible(false); m_panel.InvalidateRows(); SetStatusMessage(L"已清空当前标签页的全部网格。", false); } break; case ToolbarAction::NONE: default: break; } if (m_hwnd != nullptr) { InvalidateRect(m_hwnd, nullptr, TRUE); } } bool Application::HandleKeyDown(WPARAM key) { const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; if (control && key == L'O') { OpenFileDialog(); return true; } if (control && key == L'T') { NewTab(true); return true; } if (control && key == L'W') { CloseTab(m_activeTab); return true; } if (control && key == VK_TAB) { if (m_tabs.size() > 1) { const bool backward = (GetKeyState(VK_SHIFT) & 0x8000) != 0; const int count = static_cast(m_tabs.size()); SetActiveTab((m_activeTab + (backward ? count - 1 : 1)) % count); } return true; } switch (key) { case VK_ESCAPE: { // Esc 的语义: // 1) 放弃当前正在进行的拖拽(典型场景:缩放的框选选区还没松开左键); // 取消后不应用选区、视图范围保持不变; // 2) 同时退出全部缩放模式,使工具栏「时间轴选区」「Y 轴选区」回到未选中态。 // Esc 不与时间光标关联:关闭光标请用工具栏「光标」按钮,避免误关指针。 bool changed = m_plot.CancelDrag(); changed = m_plot.ExitZoomModes() || changed; return changed; } case VK_ADD: case VK_OEM_PLUS: m_plot.ZoomTimeStep(1.4); return true; case VK_SUBTRACT: case VK_OEM_MINUS: m_plot.ZoomTimeStep(1.0 / 1.4); return true; case VK_LEFT: m_plot.PanTimeByFraction(-0.12); return true; case VK_RIGHT: m_plot.PanTimeByFraction(0.12); return true; case VK_UP: m_plot.ScrollLanesByFraction(-0.12); return true; case VK_DOWN: m_plot.ScrollLanesByFraction(0.12); return true; default: break; } if (key == L'F') { m_plot.FitAll(); return true; } return false; } void Application::UpdateDragCursor(int x, int y) { // 鼠标捕获期间不再有 WM_SETCURSOR,只能自己按当前状态设置光标。 HCURSOR cursor = nullptr; switch (m_activeTarget) { case Target::PLOT: cursor = m_plot.QueryCursor(x, y); break; case Target::SPLITTER: // 拖分隔条 → 左右箭头(否则按下瞬间光标会跳回箭头)。 cursor = LoadCursorW(nullptr, IDC_SIZEWE); break; case Target::EDIT_FIELD: // 输入框内拖拽框选 → I 形光标。 cursor = LoadCursorW(nullptr, IDC_IBEAM); break; default: break; } SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW)); } bool Application::HandleMouseMove(int x, int y, bool buttonDown) { if (buttonDown && m_activeTarget != Target::NONE) { switch (m_activeTarget) { case Target::SPLITTER: if (m_draggingSplitter && m_scale > 0.0f) { const float desired = static_cast(x - m_splitterGrabOffset) / m_scale; // ⚠️ 上限必须与 `Relayout()` 里**同一个公式**(`min(kMaxPanelWidth, 客户区宽 × 0.45)`): // 旧实现用 `min(kMaxPanelWidth, kMinClientWidth / m_scale)` 恒等于 `kMaxPanelWidth`, // 窄窗口下能把面板拖到 400 逻辑像素,随后 `Relayout` 又悄悄夹回来 ⇒ 拖动时宽度回弹。 const float clientWidth = static_cast(std::max(0, m_backWidth)) / m_scale; const float maximum = std::min(kMaxPanelWidth, std::max(0.0f, clientWidth) * 0.45f); m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum)); Relayout(); return true; } return false; case Target::PLOT: return m_plot.OnMouseMove(x, y); case Target::PANEL: return m_panel.OnMouseMove(x, y); case Target::TOOLBAR: return m_toolbar.OnMouseMove(x, y); case Target::TAB_BAR: return m_tabBar.OnMouseMove(x, y); case Target::EDIT_FIELD: { // 输入框拖拽框选:按当前正在编辑的组件分发。 bool changed = m_tabBar.OnEditMouseMove(x); changed = m_panel.OnEditMouseMove(x) || changed; changed = m_plot.OnEditMouseMove(x) || changed; changed = m_colorPicker.OnEditMouseMove(x) || changed; return changed; } case Target::NONE: default: return false; } } bool changed = false; changed = (m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed; // ⚠️ 按住工具栏按钮期间**绝不能**走 `OnMouseLeave()`:它会清掉按下态(`m_pressedIndex`), // 于是随后在按钮上松手时什么也不会发生 —— 这正是“快速点击工具栏按钮时状态没切换” // 的另一个成因(鼠标带着按下态从按钮边缘移出时会命中这里)。 if (m_toolbar.HitTest(x, y)) { changed = m_toolbar.OnMouseMove(x, y) || changed; } else if (m_activeTarget != Target::TOOLBAR) { changed = m_toolbar.OnMouseLeave() || changed; } changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed; changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed; return changed; } bool Application::ApplyPendingPanelScroll() { SignalRef ref; if (!m_plot.TakePanelScrollRequest(ref) || !ref.IsValid()) { return false; } // 平滑滚动(带动画):用户要求"是滚动的而不是立刻翻到目标信号位置"。 m_panel.ScrollToSignalAnimated(ref); return true; } bool Application::ApplyPendingPlotLaneScroll() { int laneIndex = -1; if (!m_panel.TakePlotLaneScrollRequest(laneIndex)) { return false; } // 目标网格不在绘图区可视区内时才真正滚动(已完整可见则不动视图)。 return m_plot.ScrollLaneIntoView(laneIndex); } int Application::ComponentLayer(const UiComponent* component) const { if (component == nullptr) { return 0; } for (std::size_t index = 0; index < m_components.size(); ++index) { if (m_components[index].component == component) { return static_cast(index) * kLayersPerComponent; } } return 0; } void Application::PaintLayoutBounds(gd::Graphics& g, int width, int height) { // ⚠️ 复用成员容器(不是局部变量):文本几何已经在**绘制期间**由 `PaintUtil` // 通过 `UiSetTextRectSink(&m_debugBoxes)` 收进来了,这里只负责补组件结构。 // // 层号与 `Render` 里下发的"文本层"**完全同源**(组件表下标 / `OverlayLayer`): // 只有两者一致,遮挡矩形才能正确地把"浮层下面的内容"(含文字框)挖掉, // 而不是反过来让下层文字穿透到浮层之上(用户 2026-10-29 报告的缺陷)。 m_debugBoxes.SetLayer(0); // 客户区外框:便于确认"窗口边缘 == 最外层边界"。 m_debugBoxes.Add(gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), 0); // 组件区域(底 → 顶;组件自己负责补齐内部结构)。 // ⚠️ 三点都很重要: // 1. `SetClip(组件区域)`:组件内部的元素可能落在自己地盘之外(典型:滚出可视区的 // 网格 `lane.rect` 的 Y 为负数),不裁剪会把边框画到工具栏/标签栏上。 // 2. 每个组件收集前**显式**把层号设成它在组件表里的下标:不能依赖 `PushLayer()` // 累加 —— 组件内部可能为了自己的浮层临时提层(`PlotView` 的浮动面板), // 一旦它没有复位,后面所有组件的层号会被整体推高,遮挡关系全乱。 // 3. 层号必须与 `Render` 里给文本下发的层号同源(见 `ComponentLayer`)。 for (std::size_t index = 0; index < m_components.size(); ++index) { const ComponentEntry& entry = m_components[index]; m_debugBoxes.SetLayer(static_cast(index) * kLayersPerComponent); if (entry.component != nullptr) { m_debugBoxes.SetClip(entry.component->Area()); entry.component->BuildBoxes(m_debugBoxes); } } // 不属于任何组件的两块:面板分隔条(由宿主直接填充色块)、菜单 / 拾色器(浮层)。 // ⚠️ 浮层**不设裁剪矩形**:菜单的二级子菜单会展开到菜单本体之外,设裁剪会把它裁掉; // 它们自己会把本体登记为"遮挡矩形"(见 `PopupMenu::BuildBoxes`), // 由绘制阶段把更低层的 box 从浮层区域里挖掉。 m_debugBoxes.SetClip(gd::RectF()); m_debugBoxes.SetLayer(OverlayLayer(0)); m_debugBoxes.Add(m_splitterRect, 0); m_debugBoxes.SetLayer(OverlayLayer(1)); m_menu.BuildBoxes(m_debugBoxes); m_debugBoxes.SetLayer(OverlayLayer(2)); m_colorPicker.BuildBoxes(m_debugBoxes); UiDebugPaintBoxList(g, m_debugBoxes); } } // namespace jdi