// Application 实现分片 —— **标签页**:新建/关闭/切换/重排、视图同步、窗口标题、 // 标签栏结果回调与标签页右键菜单。 // // 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 // 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出, // **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11: // 标签页 = 本文件;派生/重采样/截取/克隆 = `ApplicationDerive.cpp`; // 上下文菜单与设置菜单 = `ApplicationMenus.cpp`;其余命令 = `ApplicationCommands.cpp`。 #include "ApplicationInternal.hpp" namespace jdi { void Application::NewTab(bool collapseGroups) { TabConfig tab; tab.SetName(NextTabName()); // 用户新建的标签页:信号分组**默认折起**(用户明确要求:列表默认紧凑; // 之后在该标签页里新加载的分组也沿用这个默认值)。 tab.SetGroupsCollapsedByDefault(collapseGroups); tab.SyncToDataSet(m_dataSet); if (collapseGroups) { tab.SetAllCollapsed(true); } // ⚠️ **新标签页不存在任何被点选的信号**(用户 2026-11-02 要求): // 选中状态是**全局共享**的(`DataSet::Selected()`),不清掉的话新建出来的标签页会 // 直接带着上一个标签页的选中信号 —— 样式编辑区于是有自己的“目标”,与新页“空白” // 的预期不符。清空后样式区回到「未选择信号」状态,直到用户手动点选任意信号。 m_dataSet.SetSelected(SignalRef()); m_tabs.push_back(std::move(tab)); SetActiveTab(static_cast(m_tabs.size()) - 1); } void Application::CloseTab(int index) { if (index < 0 || index >= static_cast(m_tabs.size())) { return; } if (m_tabs.size() <= 1) { // ⚠️ **唯一标签页不弹警告**(用户 2026-11-02 要求):直接删掉它并新建一个默认的 // 「视图 1」—— 组全部折起、无选中信号、样式区无目标(由 `NewTab(true)` 保证)。 // ⚠️ `m_tabs` 清空后 `NextTabName()` 必然返回「视图 1」,编号自然从 1 重新开始。 // erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(它们的上下文仍指向 // 刚被释放的 `TabConfig`);`NewTab` 里的 `SetActiveTab` 会第一时间重新绑定。 m_tabs.erase(m_tabs.begin() + index); m_activeTab = 0; NewTab(true); return; } m_tabs.erase(m_tabs.begin() + index); int next = index; if (next >= static_cast(m_tabs.size())) { next = static_cast(m_tabs.size()) - 1; } SetActiveTab(next); } void Application::SetActiveTab(int index) { if (index < 0 || index >= static_cast(m_tabs.size())) { return; } m_activeTab = index; // 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。 m_panel.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); m_plot.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); RefreshTabBar(); Relayout(); // 切换标签页后把该页已选中的信号滚动到可见位置。 // 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。 m_panel.ScrollToSignal(m_dataSet.Selected()); SynchronizeView(true); UpdateWindowTitle(); InvalidateRect(m_hwnd, nullptr, TRUE); } void Application::RefreshTabBar() { std::vector names; names.reserve(m_tabs.size()); for (const TabConfig& tab : m_tabs) { names.push_back(tab.Name()); } m_tabBar.SetTabs(names); m_tabBar.SetActiveIndex(m_activeTab); } void Application::SynchronizeView(bool force) { if (m_tabs.empty() || m_activeTab < 0) { return; } TabConfig& tab = m_tabs[static_cast(m_activeTab)]; const int visibleCount = tab.VisibleCount(); const bool anyVisible = tab.AnyVisible(); const std::size_t laneCount = tab.Lanes().size(); const bool changed = (visibleCount != m_cachedVisibleCount) || (anyVisible != m_cachedAnyVisible) || (laneCount != m_cachedLaneCount); if (!changed && !force) { return; } const std::size_t previousLaneCount = m_cachedLaneCount; m_cachedVisibleCount = visibleCount; m_cachedAnyVisible = anyVisible; m_cachedLaneCount = laneCount; // 标签页首次出现可见信号时,把时间轴对齐到可见数据的范围。 // 没有任何可见信号时(未勾选 / 全部取消勾选 / 清空网格)主动复位自适应标记, // 这样用户下一次勾选信号时会重新做一次自适应,而不是停留在上次的视图范围。 if (!anyVisible) { tab.ViewInitialized() = false; } else if (!tab.ViewInitialized()) { // 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由 // `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。 m_plot.FitAll(); // ⚠️ **网格纵向滚动也要回到最上方**(用户 2026-10-17 要求): // “有过网格 → 没有信号显示 → 又加载了需要滚动条数量的网格”这一整类情形, // 新出现的网格集必须从最上方开始看。旧实现保留了上一次的 `LaneScroll()`, // 于是重新显示后会**停在与取消显示时相同的滚动位置**(用户报告)。 // 放在 `FitAll()` 之后:`FitAll` 本身不改变滚动量(§11.2 第 112 条), // 这里显式归零即可;随后 `Relayout`/`RefreshLanes` 会重算几何。 tab.LaneScroll() = 0.0f; // 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时, // 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。 // 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。 m_plot.ResetCursorPlacementOnFreshContent(); } const bool laneCountChanged = (laneCount != previousLaneCount); if (laneCountChanged || force) { // 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。 Relayout(); } else { m_plot.RefreshLanes(); } } void Application::SyncTabsToDataSet() { for (TabConfig& tab : m_tabs) { tab.SyncToDataSet(m_dataSet); } // 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。 m_dataSet.ValidateSelection(); m_panel.InvalidateRows(); // 分组/信号结构变化会让信号引用与分组地址改变,派生缓存必须整体作废。 m_plot.InvalidateDerivedCaches(); // 强制下一次同步重新计算视图状态。 m_cachedVisibleCount = -1; m_cachedAnyVisible = false; m_cachedLaneCount = 0; if (m_hwnd != nullptr) { InvalidateRect(m_hwnd, nullptr, TRUE); } } void Application::UpdateWindowTitle() { // 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。 // 除内存占用外不再附带标签页、文件数量等无关信息。 // ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。 std::wstring title = kWindowTitle; title += kTitleSeparator; title += kRamTitlePrefix; title += FormatByteSize(ProcessPrivateWorkingSetBytes()); // 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。 if (title == m_windowTitleText) { return; } m_windowTitleText = title; SetWindowTextW(m_hwnd, m_windowTitleText.c_str()); } void Application::OnTabBarResult(const TabBarResult& result) { switch (result.type) { case TabBarResult::Type::ADD: NewTab(true); break; case TabBarResult::Type::SELECT: if (result.index != m_activeTab) { SetActiveTab(result.index); } break; case TabBarResult::Type::CLOSE: CloseTab(result.index); break; case TabBarResult::Type::CONTEXT: ShowTabContextMenu(result); break; case TabBarResult::Type::REORDER: MoveTab(result.index, result.targetIndex); break; case TabBarResult::Type::RENAME: if (result.index >= 0 && result.index < static_cast(m_tabs.size()) && !result.text.empty()) { m_tabs[static_cast(result.index)].SetName(result.text); RefreshTabBar(); // SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。 Relayout(); InvalidateRect(m_hwnd, nullptr, TRUE); } break; case TabBarResult::Type::NONE: default: break; } } void Application::MoveTab(int from, int target) { if (from < 0 || from >= static_cast(m_tabs.size())) { return; } if (target < 0 || target > static_cast(m_tabs.size())) { return; } // 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。 int destination = target; if (destination > from) { --destination; } destination = std::max(0, std::min(destination, static_cast(m_tabs.size()) - 1)); if (destination == from) { return; } // 保持当前激活的标签页在重排后仍处于激活状态。 TabConfig moved = m_tabs[static_cast(from)]; const int activeIndex = m_activeTab; m_tabs.erase(m_tabs.begin() + from); m_tabs.insert(m_tabs.begin() + destination, std::move(moved)); int newActive = activeIndex; if (activeIndex == from) { newActive = destination; } else if (from < activeIndex && destination >= activeIndex) { --newActive; } else if (from > activeIndex && destination <= activeIndex) { ++newActive; } SetActiveTab(newActive); InvalidateRect(m_hwnd, nullptr, TRUE); } void Application::ShowTabContextMenu(const TabBarResult& result) { if (result.index < 0 || result.index >= static_cast(m_tabs.size())) { return; } m_tabMenuIndex = result.index; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = m_tabs[static_cast(result.index)].Name(); items.push_back(header); MenuItem create; create.label = L"新建标签页"; create.id = kMenuTabNew; items.push_back(create); MenuItem close; close.label = L"关闭该标签页"; close.id = kMenuTabClose; close.enabled = (m_tabs.size() > 1); items.push_back(close); MenuItem closeOthers; closeOthers.label = L"关闭其它标签页"; closeOthers.id = kMenuTabCloseOthers; closeOthers.enabled = (m_tabs.size() > 1); items.push_back(closeOthers); // 菜单绘制在客户区内,因此把屏幕坐标换算为客户区坐标。 POINT anchor = {result.screenX, result.screenY}; ScreenToClient(m_hwnd, &anchor); 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::TAB_BAR, bounds, anchor.x, anchor.y, items); } bool Application::ApplyPendingRename() { SignalRef ref; std::wstring name; if (!m_plot.TakePendingRename(ref, name)) { return false; } if (!m_dataSet.RenameSignal(ref.groupIndex, ref.signalIndex, name)) { return false; } // 改名只影响显示,但需要刷新左侧列表与绘图区图例。 m_panel.InvalidateRows(); SetStatusMessage(L"信号已重命名为:" + name, false); InvalidateRect(m_hwnd, nullptr, TRUE); return true; } } // namespace jdi