#include "Application.hpp" #include #include #include #include #include #include #include #include "CsvTable.hpp" #include "ResourceIds.h" #include "Utf8Util.hpp" #ifndef WM_DPICHANGED #define WM_DPICHANGED 0x02E0 #endif #ifndef DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 #define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (reinterpret_cast(-4)) #endif namespace jdi { namespace { const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */ const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题(仅显示应用名称)。 */ const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */ const float kMinPanelWidth = 260.0f; /**< 面板最小宽度(逻辑像素)。 */ const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */ const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */ const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */ const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */ const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */ const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */ const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ /** * @brief 只失效绘图区所在的矩形。 * * 配合 WM_PAINT 中对更新区域的判断,可以走“只重绘、只回送绘图区”的快路径; * 绘图区矩形无效时退化为整窗失效,保证不会漏画。 * * @param [IN] hwnd 窗口句柄。 * @param [IN] plotRect 绘图区矩形(客户区坐标)。 */ void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect) { RECT invalid = {}; invalid.left = static_cast(std::floor(plotRect.X)); invalid.top = static_cast(std::floor(plotRect.Y)); invalid.right = static_cast(std::ceil(plotRect.X + plotRect.Width)); invalid.bottom = static_cast(std::ceil(plotRect.Y + plotRect.Height)); if (invalid.right > invalid.left && invalid.bottom > invalid.top) { InvalidateRect(hwnd, &invalid, FALSE); } else { InvalidateRect(hwnd, nullptr, FALSE); } } /** @brief 标签页右键菜单命令 ID。 */ const int kMenuTabNew = 1; const int kMenuTabClose = 2; const int kMenuTabCloseOthers = 3; /** * @brief 从应用资源中按指定尺寸加载图标。 * @param [IN] cx 期望宽度(像素)。 * @param [IN] cy 期望高度(像素)。 * @return 图标句柄,加载失败返回 nullptr。 */ HICON LoadAppIcon(int cx, int cy) { HINSTANCE module = GetModuleHandleW(nullptr); // LR_DEFAULTSIZE 之外的显式尺寸请求会挑选 ICO 中最接近的一帧并由系统缩放, // 因此 256/64/48/32/16 的多尺寸资源在任意 DPI 下都能取到合适的一帧。 HICON icon = static_cast(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, cx, cy, LR_DEFAULTCOLOR)); if (icon == nullptr) { icon = static_cast(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, 0, 0, LR_DEFAULTCOLOR | LR_DEFAULTSIZE)); } return icon; } /** * @brief 后台加载任务的共享状态。 * * `done` 是唯一的同步点:工作线程写完其它字段后以 release 语义置位, * 主线程以 acquire 语义读取,因此无需额外锁。 */ struct AsyncLoadState { std::atomic done{false}; /**< 工作线程是否已结束。 */ std::atomic cancelled{false}; /**< 是否请求取消。 */ std::atomic progress{0.0f}; /**< 0-1 进度。 */ std::atomic bytesRead{0}; /**< 已读取字节数(解析阶段)。 */ std::atomic totalBytes{0}; /**< 文件总字节数(0 表示未知)。 */ bool ok = false; /**< 加载是否成功。 */ std::wstring error; /**< 失败原因。 */ DataFile file; /**< 构建结果。 */ }; /** * @brief 工作线程主体:解析 CSV 并构建数据文件(不接触 DataSet)。 * @param [IN,OUT] state 共享状态。 * @param [IN] path 文件路径。 * @param [IN] paletteBase 调色板起始序号。 */ void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase) { // 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。 CsvProgress parseProgress = [state](const CsvProgressInfo& info) { state->progress.store(static_cast(info.ratio * 0.75), std::memory_order_relaxed); if (info.hasBytes) { state->bytesRead.store(info.bytesRead, std::memory_order_relaxed); state->totalBytes.store(info.totalBytes, std::memory_order_relaxed); } return !state->cancelled.load(std::memory_order_relaxed); }; CsvTable table; std::wstring error; if (!LoadCsvTable(path, table, error, parseProgress)) { state->error = error; state->done.store(true, std::memory_order_release); return; } CsvProgress buildProgress = [state](const CsvProgressInfo& info) { state->progress.store(static_cast(0.75 + info.ratio * 0.25), std::memory_order_relaxed); return !state->cancelled.load(std::memory_order_relaxed); }; DataFile file; if (!BuildDataFile(std::move(table), path, paletteBase, file, error, buildProgress)) { state->error = error; state->done.store(true, std::memory_order_release); return; } state->file = std::move(file); state->ok = true; state->progress.store(1.0f, std::memory_order_relaxed); state->done.store(true, std::memory_order_release); } /** * @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。 */ void EnableDpiAwareness() { HMODULE user32 = GetModuleHandleW(L"user32.dll"); if (user32 != nullptr) { typedef BOOL(WINAPI * SetProcessDpiAwarenessContextFn)(HANDLE); SetProcessDpiAwarenessContextFn setContext = reinterpret_cast( reinterpret_cast(GetProcAddress(user32, "SetProcessDpiAwarenessContext"))); if (setContext != nullptr && setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE) { return; } } SetProcessDPIAware(); } /** * @brief 查询屏幕 DPI。 * @return DPI 数值,失败时返回 96。 */ UINT QuerySystemDpi() { HDC hdc = GetDC(nullptr); if (hdc == nullptr) { return 96u; } const int dpi = GetDeviceCaps(hdc, LOGPIXELSX); ReleaseDC(nullptr, hdc); return (dpi > 0) ? static_cast(dpi) : 96u; } /** * @brief 依据 DPI 缩放逻辑像素。 * @param [IN] value 逻辑像素值。 * @param [IN] scale 缩放比例。 * @return 物理像素值。 */ float ScaleOf(float value, float scale) { return value * scale; } } Application& Application::Instance() { static Application instance; return instance; } LRESULT CALLBACK Application::WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { Application* self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); if (message == WM_NCCREATE) { CREATESTRUCTW* createStruct = reinterpret_cast(lParam); self = reinterpret_cast(createStruct->lpCreateParams); SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(self)); } if (self != nullptr) { return self->HandleMessage(hwnd, message, wParam, lParam); } return DefWindowProcW(hwnd, message, wParam, lParam); } int Application::Run(HINSTANCE instance, int showCommand) { if (!Initialize(instance, showCommand)) { return 1; } MSG message = {}; while (GetMessageW(&message, nullptr, 0, 0) > 0) { TranslateMessage(&message); DispatchMessageW(&message); } Shutdown(); return static_cast(message.wParam); } bool Application::Initialize(HINSTANCE instance, int showCommand) { m_instance = instance; EnableDpiAwareness(); m_dpi = QuerySystemDpi(); m_scale = static_cast(m_dpi) / 96.0f; gd::GdiplusStartupInput startupInput; if (gd::GdiplusStartup(&m_gdiplusToken, &startupInput, nullptr) != gd::Ok) { MessageBoxW(nullptr, L"GDI+ 初始化失败,程序无法启动。", kWindowTitle, MB_ICONERROR | MB_OK); return false; } m_gdiplusReady = true; m_theme = SolarizedLight(); if (!m_fonts.Create(m_dpi)) { m_gdiplusReady = false; gd::GdiplusShutdown(m_gdiplusToken); MessageBoxW(nullptr, L"界面字体创建失败,程序无法启动。", kWindowTitle, MB_ICONERROR | MB_OK); return false; } WNDCLASSEXW windowClass = {}; windowClass.cbSize = sizeof(windowClass); windowClass.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS; windowClass.lpfnWndProc = &Application::WindowProc; windowClass.hInstance = instance; windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); windowClass.hbrBackground = nullptr; windowClass.lpszClassName = kWindowClassName; // 使用应用自己的图标资源(IDI_APP_ICON); // 原先取 IDI_APPLICATION 是系统默认图标,导致标题栏与任务栏缩略图显示成"通用应用"图标。 windowClass.hIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON)); windowClass.hIconSm = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON)); if (RegisterClassExW(&windowClass) == 0) { MessageBoxW(nullptr, L"窗口类注册失败。", kWindowTitle, MB_ICONERROR | MB_OK); return false; } // 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。 RECT workArea = {0, 0, static_cast(ScaleOf(1380.0f, m_scale)), static_cast(ScaleOf(880.0f, m_scale))}; SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0); const int workWidth = workArea.right - workArea.left; const int workHeight = workArea.bottom - workArea.top; const int windowWidth = std::min(static_cast(ScaleOf(1380.0f, m_scale)), workWidth - 24); const int windowHeight = std::min(static_cast(ScaleOf(880.0f, m_scale)), workHeight - 24); const int windowX = workArea.left + std::max(0, (workWidth - windowWidth) / 2); const int windowY = workArea.top + std::max(0, (workHeight - windowHeight) / 2); m_hwnd = CreateWindowExW(WS_EX_ACCEPTFILES, kWindowClassName, kWindowTitle, WS_OVERLAPPEDWINDOW, windowX, windowY, windowWidth, windowHeight, nullptr, nullptr, instance, this); if (m_hwnd == nullptr) { MessageBoxW(nullptr, L"窗口创建失败。", kWindowTitle, MB_ICONERROR | MB_OK); return false; } m_tabBar.Setup(&m_fonts, &m_theme); m_tabBar.SetScale(m_scale); m_tabBar.SetHostWindow(m_hwnd); m_toolbar.Setup(&m_fonts, &m_theme); m_toolbar.SetScale(m_scale); m_menu.Setup(&m_fonts, &m_theme); m_menu.SetScale(m_scale); m_colorPicker.Setup(&m_fonts, &m_theme); m_colorPicker.SetScale(m_scale); m_colorPicker.SetOwnerWindow(m_hwnd); m_panel.Setup(m_hwnd, &m_fonts, &m_theme, &m_menu, &m_colorPicker); m_panel.SetScale(m_scale); m_plot.Setup(&m_fonts, &m_theme); m_plot.SetScale(m_scale); m_plot.SetHostWindow(m_hwnd); m_menu.SetHostWindow(m_hwnd); // 标题栏小图标与任务栏/Alt+Tab 缩略图大图标分开设置(同一个多尺寸 ICO 资源的不同尺寸)。 HICON bigIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON)); HICON smallIcon = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON)); if (bigIcon != nullptr) { SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast(bigIcon)); } if (smallIcon != nullptr) { SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast(smallIcon)); } // 创建首个标签页。 NewTab(); Relayout(); ShowWindow(m_hwnd, showCommand); UpdateWindow(m_hwnd); LoadCommandLineFiles(); return true; } void Application::Shutdown() { ReleaseBackBuffer(); m_fonts.Destroy(); // 拾色器缓存了 GDI+ 位图(色域方块):必须在 GdiplusShutdown 之前释放, // 否则位图析构会发生在 GDI+ 关闭之后,直接访问违例。 m_colorPicker.Close(); if (m_gdiplusReady) { gd::GdiplusShutdown(m_gdiplusToken); m_gdiplusReady = false; } } void Application::ApplyDpi(UINT dpi) { if (dpi == 0) { return; } m_dpi = dpi; m_scale = static_cast(dpi) / 96.0f; m_fonts.Create(dpi); m_tabBar.SetScale(m_scale); m_toolbar.SetScale(m_scale); m_menu.SetScale(m_scale); m_colorPicker.SetScale(m_scale); m_panel.SetScale(m_scale); m_plot.SetScale(m_scale); ReleaseBackBuffer(); Relayout(); } TabConfig& Application::ActiveTab() { static TabConfig fallback; if (m_activeTab < 0 || m_activeTab >= static_cast(m_tabs.size())) { return fallback; } return m_tabs[static_cast(m_activeTab)]; } const TabConfig& Application::ActiveTab() const { static const TabConfig fallback; if (m_activeTab < 0 || m_activeTab >= static_cast(m_tabs.size())) { return fallback; } return m_tabs[static_cast(m_activeTab)]; } void Application::NewTab() { TabConfig tab; tab.SetName(NextTabName()); tab.SyncToDataSet(m_dataSet); m_tabs.push_back(std::move(tab)); SetActiveTab(static_cast(m_tabs.size()) - 1); } std::wstring Application::NextTabName() const { // 取当前未被占用的最小编号,使关闭标签页后编号可以被复用(3 开 2 关再新建回到「视图 2」)。 for (int number = 1; number <= static_cast(m_tabs.size()) + 1; ++number) { const std::wstring candidate = L"视图 " + std::to_wstring(number); bool used = false; for (const TabConfig& tab : m_tabs) { if (tab.Name() == candidate) { used = true; break; } } if (!used) { return candidate; } } return L"视图 " + std::to_wstring(m_tabs.size() + 1); } void Application::CloseTab(int index) { if (index < 0 || index >= static_cast(m_tabs.size())) { return; } if (m_tabs.size() <= 1) { SetStatusMessage(L"至少需要保留一个标签页。", 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()) { m_plot.FitAll(); } 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::RemoveFile(int fileIndex) { const DataFile* file = m_dataSet.FileAt(fileIndex); if (file == nullptr) { return; } const std::wstring name = file->fileName; // 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。 for (TabConfig& tab : m_tabs) { tab.RemapAfterFileRemoval(fileIndex); } m_dataSet.RemapSelectionAfterFileRemoval(fileIndex); m_dataSet.RemoveFile(fileIndex); SyncTabsToDataSet(); SetStatusMessage(L"已从工作区移除:" + name, false); UpdateWindowTitle(); } void Application::RemoveSignal(int fileIndex, int signalIndex) { SignalRef target; target.fileIndex = fileIndex; target.signalIndex = signalIndex; const SignalData* signal = m_dataSet.Resolve(target); const DataFile* file = m_dataSet.FileAt(fileIndex); if (signal == nullptr || file == nullptr) { return; } const std::wstring name = signal->Title(); if (file->signals.size() <= 1) { // 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。 RemoveFile(fileIndex); return; } // 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。 for (TabConfig& tab : m_tabs) { tab.RemapAfterSignalRemoval(fileIndex, signalIndex); } m_dataSet.RemapSelectionAfterSignalRemoval(fileIndex, signalIndex); m_dataSet.RemoveSignal(fileIndex, signalIndex); SyncTabsToDataSet(); SetStatusMessage(L"已从工作区移除信号:" + name, false); } void Application::Relayout() { RECT client = {}; GetClientRect(m_hwnd, &client); const float width = static_cast(client.right - client.left); const float height = static_cast(client.bottom - client.top); if (width <= 1.0f || height <= 1.0f) { return; } const float tabBarHeight = m_tabBar.DesiredHeight(); const float toolbarHeight = m_toolbar.DesiredHeight(); const float splitterWidth = ScaleOf(kSplitterWidth, m_scale); m_tabBarRect = gd::RectF(0.0f, 0.0f, width, tabBarHeight); m_toolbarRect = gd::RectF(0.0f, m_tabBarRect.Y + m_tabBarRect.Height, width, toolbarHeight); // 不再保留底部状态栏,内容区一直延伸到窗口底部。 const float contentTop = m_toolbarRect.Y + m_toolbarRect.Height; const float contentHeight = std::max(0.0f, height - contentTop); float panelWidth = ScaleOf(m_panelWidth, m_scale); const float maximum = std::min(ScaleOf(kMaxPanelWidth, m_scale), width * 0.45f); panelWidth = std::max(ScaleOf(kMinPanelWidth, m_scale), std::min(panelWidth, maximum)); m_panelWidth = panelWidth / m_scale; m_panelRect = gd::RectF(0.0f, contentTop, panelWidth, contentHeight); m_splitterRect = gd::RectF(panelWidth, contentTop, splitterWidth, contentHeight); m_plotRect = gd::RectF(panelWidth + splitterWidth, contentTop, std::max(0.0f, width - panelWidth - splitterWidth), contentHeight); m_tabBar.Layout(m_tabBarRect); m_toolbar.Layout(m_toolbarRect); m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height)); m_panel.Layout(m_panelRect); m_plot.Layout(m_plotRect); // 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。 m_chromeDirty = true; } void Application::EnsureBackBuffer(int width, int height) { if (m_backDc != nullptr && width == m_backWidth && height == m_backHeight) { return; } ReleaseBackBuffer(); // 新建的后备位图内容未定义,必须整帧重绘一次。 m_chromeDirty = true; HDC screenDc = GetDC(m_hwnd); if (screenDc == nullptr) { return; } m_backDc = CreateCompatibleDC(screenDc); if (m_backDc != nullptr) { m_backBitmap = CreateCompatibleBitmap(screenDc, width, height); if (m_backBitmap != nullptr) { m_backOldBitmap = SelectObject(m_backDc, m_backBitmap); m_backWidth = width; m_backHeight = height; } else { DeleteDC(m_backDc); m_backDc = nullptr; } } ReleaseDC(m_hwnd, screenDc); } void Application::ReleaseBackBuffer() { if (m_backDc != nullptr) { if (m_backOldBitmap != nullptr) { SelectObject(m_backDc, m_backOldBitmap); m_backOldBitmap = nullptr; } DeleteDC(m_backDc); m_backDc = nullptr; } if (m_backBitmap != nullptr) { DeleteObject(m_backBitmap); m_backBitmap = nullptr; } m_backWidth = 0; m_backHeight = 0; } void Application::Render() { RECT client = {}; GetClientRect(m_hwnd, &client); const int width = client.right - client.left; const int height = client.bottom - client.top; if (width <= 0 || height <= 0) { return; } EnsureBackBuffer(width, height); if (m_backDc == nullptr) { return; } // 面板内展开/收起自定义颜色面板等操作会改变面板所需高度。 if (m_panel.TakeLayoutDirty()) { Relayout(); } // 任何交互(勾选、移动网格、切换标签页)之后都在此统一收敛视图状态。 SynchronizeView(false); m_toolbar.SetDocumentLoaded(!m_dataSet.Empty()); // 工具栏可切换按钮的选中状态始终与当前标签页配置保持一致。 m_toolbar.SetToggleState(ToolbarAction::AUTO_FIT_Y, m_plot.AllLanesAutoFitToSelection()); m_toolbar.SetToggleState(ToolbarAction::ZOOM_SELECT, m_plot.CurrentZoomMode() == PlotZoomMode::SELECT); m_toolbar.SetToggleState(ToolbarAction::CURSOR, m_plot.CursorEnabled()); // 「光标」按钮文本随模式变化:常态=光标,单光标=光标x1,双光标=光标x2。 // 文本变化会影响按钮宽度,因此需要重新排布一次。 { const PlotCursorMode cursorMode = m_plot.CursorMode(); const std::wstring cursorLabel = (cursorMode == PlotCursorMode::SINGLE) ? L"光标x1" : (cursorMode == PlotCursorMode::DOUBLE) ? L"光标x2" : L"光标"; if (m_toolbar.SetActionLabel(ToolbarAction::CURSOR, cursorLabel)) { Relayout(); } } // 工具栏/标签栏/面板各自带悬停态,内容只在鼠标位于其中或发生交互时改变。 // 绘图区内的拖拽(如双光标左右移动)期间这些区域完全静止,跳过其绘制, // 每帧省下约 6-8ms;弹出菜单 / 拾色器 / 加载遮罩会覆盖到装饰区域,必须整帧重绘。 const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen(); const bool chromeDirty = m_chromeDirty || m_loading || overlaysVisible; m_chromeDirty = false; // 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。 const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation(); { gd::Graphics g(m_backDc); g.SetSmoothingMode(gd::SmoothingModeAntiAlias); g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); if (chromeDirty) { UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), m_theme.windowBg); m_tabBar.Paint(g); m_toolbar.Paint(g); m_panel.Paint(g); UiFillRect(g, m_splitterRect, m_theme.border); } m_plot.Paint(g); // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 m_menu.Paint(g); m_colorPicker.Paint(g); // 加载遮罩层在一切内容之上(包括菜单与拾色器),形成模态观感。 PaintLoadingOverlay(g, width, height); } HDC windowDc = GetDC(m_hwnd); if (windowDc != nullptr) { if (fullFrame) { BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); } else { // PlotView 会把自身绘制严格裁剪在 m_plotRect 内,因此只回送该矩形即可。 const int px = static_cast(std::floor(m_plotRect.X)); const int py = static_cast(std::floor(m_plotRect.Y)); const int pw = static_cast(std::ceil(m_plotRect.X + m_plotRect.Width)) - px; const int ph = static_cast(std::ceil(m_plotRect.Y + m_plotRect.Height)) - py; BitBlt(windowDc, px, py, pw, ph, m_backDc, px, py, SRCCOPY); } ReleaseDC(m_hwnd, windowDc); } // 提示浮层需要停留后淡出:仅在其仍可见时保持定时重绘,淡出结束即停表。 if (m_plot.HintNeedsAnimation()) { if (m_hintTimerActive == false) { SetTimer(m_hwnd, kHintTimerId, kHintTimerPeriodMs, nullptr); m_hintTimerActive = true; } } else if (m_hintTimerActive) { KillTimer(m_hwnd, kHintTimerId); m_hintTimerActive = false; } } void Application::SetStatusMessage(const std::wstring& text, bool isError) { // 界面不再占用底部空间显示常驻提示;仅把真实错误以对话框形式反馈给用户。 if (!isError || text.empty()) { return; } MessageBoxW(m_hwnd, text.c_str(), kWindowTitle, MB_ICONWARNING | MB_OK); } void Application::UpdateWindowTitle() { // 标题栏只显示应用名称,不附带标签页、文件数量等无关信息。 SetWindowTextW(m_hwnd, kWindowTitle); } 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 = L"CSV 数据文件 (*.csv)\0*.csv\0文本数据文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0\0"; 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"已加载 " + std::to_wstring(loaded) + L" 个文件,请在左侧勾选需要查看的信号。", false); } } bool Application::LoadFile(const std::wstring& path) { if (path.empty()) { return false; } // 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。 DataFile file; std::wstring error; if (!LoadFileAsync(path, file, error)) { if (!error.empty()) { SetStatusMessage(L"解析失败:" + error, true); } InvalidateRect(m_hwnd, nullptr, FALSE); return false; } int fileIndex = -1; // 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。 if (!m_dataSet.AddDataFile(std::move(file), fileIndex)) { SetStatusMessage(L"解析失败:数据为空。", true); InvalidateRect(m_hwnd, nullptr, FALSE); return false; } // 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。 SyncTabsToDataSet(); UpdateWindowTitle(); const DataFile* loaded = m_dataSet.FileAt(fileIndex); if (loaded != nullptr) { std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + loaded->Title() + L"(" + FormatCount(static_cast(loaded->time.size())) + L" 行采样," + std::to_wstring(loaded->signals.size()) + L" 条信号,时间列:" + loaded->timeColumnName + L")"; if (!loaded->timeMonotonic) { message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。"; } SetStatusMessage(message, false); } return true; } bool Application::LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error) { error.clear(); AsyncLoadState state; const std::size_t paletteBase = m_dataSet.SignalCount(); m_loading = true; m_loadingProgress = 0.0f; m_loadingBytesRead = 0; m_loadingTotalBytes = 0; const std::size_t slashPos = path.find_last_of(L"\\/"); m_loadingText = (slashPos == std::wstring::npos) ? path : path.substr(slashPos + 1); std::thread worker(RunAsyncLoad, &state, path, paletteBase); // 主线程循环:抽送消息 + 刷新动画,直到后台完成或用户取消。 while (!state.done.load(std::memory_order_acquire)) { MSG message = {}; while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { if (message.message == WM_QUIT) { // 退出请求:让后台停下并重新投递 WM_QUIT,由主循环正常收尾。 state.cancelled.store(true, std::memory_order_relaxed); PostQuitMessage(static_cast(message.wParam)); break; } // 加载期间屏蔽鼠标与其它按键,避免重入加载或误改数据; // 保留 Esc 作为取消入口。 if (message.message >= WM_MOUSEFIRST && message.message <= WM_MOUSELAST) { continue; } if (message.message == WM_KEYDOWN && message.wParam == VK_ESCAPE) { state.cancelled.store(true, std::memory_order_relaxed); continue; } if (message.message >= WM_KEYFIRST && message.message <= WM_KEYLAST) { continue; } TranslateMessage(&message); DispatchMessageW(&message); } m_loadingProgress = state.progress.load(std::memory_order_relaxed); m_loadingBytesRead = state.bytesRead.load(std::memory_order_relaxed); m_loadingTotalBytes = state.totalBytes.load(std::memory_order_relaxed); InvalidateRect(m_hwnd, nullptr, FALSE); UpdateWindow(m_hwnd); Sleep(15); } m_loadingProgress = 1.0f; m_loadingBytesRead = m_loadingTotalBytes; worker.join(); m_loading = false; if (state.cancelled.load(std::memory_order_relaxed)) { // 用户主动取消:不弹错误框(与 Esc 取消缩放选区的静默语义一致)。 InvalidateRect(m_hwnd, nullptr, TRUE); return false; } if (!state.ok) { error = state.error; return false; } file = std::move(state.file); return true; } void Application::PaintLoadingOverlay(gd::Graphics& g, int width, int height) { if (!m_loading) { return; } // 半透明遮罩压暗整个界面,形成明确的"正在加载"状态。 UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), 0x66000000u); const float cardWidth = std::min(420.0f * m_scale, static_cast(width) - 40.0f * m_scale); const float cardHeight = 132.0f * m_scale; const gd::RectF card((static_cast(width) - cardWidth) * 0.5f, (static_cast(height) - cardHeight) * 0.5f, cardWidth, cardHeight); UiFillRoundRect(g, card, 8.0f * m_scale, m_theme.cardBg); UiDrawRoundRect(g, card, 8.0f * m_scale, m_theme.borderStrong, 1.0f); // 旋转弧线:用时间驱动的起始角度形成持续的加载动画。 const float spinnerRadius = 11.0f * m_scale; const gd::RectF spinner(card.X + 26.0f * m_scale, card.Y + 30.0f * m_scale - spinnerRadius, spinnerRadius * 2.0f, spinnerRadius * 2.0f); m_loadingSpin += 12.0f; if (m_loadingSpin >= 360.0f) { m_loadingSpin -= 360.0f; } gd::Pen trackPen(ToGdiColor(m_theme.border), 3.0f * m_scale); gd::Pen spinPen(ToGdiColor(m_theme.accent), 3.0f * m_scale); g.DrawArc(&trackPen, spinner, 0.0f, 359.0f); g.DrawArc(&spinPen, spinner, m_loadingSpin, 110.0f); const float textLeft = card.X + 52.0f * m_scale; const float textWidth = cardWidth - 52.0f * m_scale - 20.0f * m_scale; UiText(g, L"正在加载数据…", m_fonts.uiBold, gd::RectF(textLeft, card.Y + 18.0f * m_scale, textWidth, 22.0f * m_scale), m_theme.text, TextAlign::LEFT); // 超长文件名用省略号截断,避免撑破卡片。 const std::wstring nameText = UiEllipsize(m_loadingText, m_fonts.uiSmall, textWidth); UiText(g, nameText, m_fonts.uiSmall, gd::RectF(textLeft, card.Y + 42.0f * m_scale, textWidth, 20.0f * m_scale), m_theme.textMuted, TextAlign::LEFT); // 进度条(复用样式区滑块以外的简单圆角矩形,填充宽度严格按比例)。 const gd::RectF track(card.X + 26.0f * m_scale, card.Y + 76.0f * m_scale, cardWidth - 52.0f * m_scale, 8.0f * m_scale); UiFillRoundRect(g, track, track.Height * 0.5f, m_theme.pressed); float ratio = m_loadingProgress; ratio = std::max(0.0f, std::min(ratio, 1.0f)); const float fillWidth = track.Width * ratio; if (fillWidth > 1.0f) { UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), track.Height * 0.5f, m_theme.accent); } wchar_t buffer[64]; swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.2f%%", ratio * 100.0); // 已读取 / 总大小:两者都只显示数字与自适应单位(GB / MB / KB),均保留两位小数; // 百分比同样是两位小数。总大小未知(如列重组阶段)时只显示百分比。 { std::wstring statusText = buffer; if (m_loadingTotalBytes > 0) { statusText = FormatByteSize(m_loadingBytesRead) + L" / " + FormatByteSize(m_loadingTotalBytes) + L" " + buffer; } UiText(g, statusText, m_fonts.monoSmall, gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale), m_theme.text, TextAlign::LEFT); } UiText(g, L"按 Esc 取消", m_fonts.uiSmall, gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale), m_theme.textMuted, TextAlign::RIGHT); } void Application::LoadCommandLineFiles() { int argumentCount = 0; LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount); if (arguments == nullptr) { return; } int loaded = 0; for (int index = 1; index < argumentCount; ++index) { const std::wstring argument = arguments[index]; if (argument.empty() || argument[0] == L'-' || argument[0] == L'/') { continue; } if (LoadFile(argument)) { ++loaded; } } LocalFree(arguments); if (loaded > 0) { SetStatusMessage(L"已加载 " + std::to_wstring(loaded) + L" 个文件,请在左侧勾选需要查看的信号。", false); } } void Application::ExecuteToolbarAction(ToolbarAction action) { switch (action) { 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(); 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; if (m_activeTarget == Target::PLOT) { cursor = m_plot.QueryCursor(x, y); } 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; const float maximum = std::min(kMaxPanelWidth, static_cast(kMinClientWidth) / m_scale); 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; changed = (m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : 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; } void Application::OnTabBarResult(const TabBarResult& result) { switch (result.type) { case TabBarResult::Type::ADD: NewTab(); 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); } bool Application::ApplyPendingRename() { SignalRef ref; std::wstring name; if (!m_plot.TakePendingRename(ref, name)) { return false; } if (!m_dataSet.SetSignalDisplayName(ref.fileIndex, ref.signalIndex, name)) { return false; } // 改名只影响显示,但需要刷新左侧列表与绘图区图例。 m_panel.InvalidateRows(); SetStatusMessage(L"信号已重命名为:" + name, false); InvalidateRect(m_hwnd, nullptr, TRUE); return true; } bool Application::BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible) { const DataFile* file = m_dataSet.FileAt(request.fileIndex); if (file == nullptr || request.signalIndex < 0 || request.signalIndex >= static_cast(file->signals.size())) { return false; } const SignalData& source = file->signals[static_cast(request.signalIndex)]; const std::wstring baseName = source.Title(); // 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。 // 多次迭代均值滤波形如「<原信号名称> (N点均值 M次迭代)」, // 弱波增强形如「<原信号名称> (弱波增强 M% N次迭代)」。 std::vector derived_values; SignalData derived; if (request.weakBoost) { ComputeWeakWaveBoost(source.values, static_cast(request.levelPercent) / 100.0, request.iterations, derived_values); derived.name = baseName + L" (弱波增强 " + std::to_wstring(request.levelPercent) + L"% " + std::to_wstring(request.iterations) + L"次迭代)"; } else { ComputeMovingAverageIterated(source.values, request.points, request.iterations, derived_values); derived.name = baseName + L" (" + std::to_wstring(request.points) + L"点均值 " + std::to_wstring(request.iterations) + L"次迭代)"; } // 2. 组装新信号。 derived.rawHeader = derived.name; derived.unit = source.unit; derived.values.assign(derived_values.begin(), derived_values.end()); // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 int newIndex = -1; if (!m_dataSet.InsertDerivedSignal(request.fileIndex, request.signalIndex, std::move(derived), newIndex)) { return false; } for (TabConfig& tab : m_tabs) { tab.RemapAfterSignalInsert(request.fileIndex, newIndex); } // 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 if (makeVisible && !m_tabs.empty()) { SignalRef derivedRef; derivedRef.fileIndex = request.fileIndex; derivedRef.signalIndex = newIndex; ActiveTab().SetVisible(derivedRef, true); SignalRef sourceRef; sourceRef.fileIndex = request.fileIndex; sourceRef.signalIndex = request.signalIndex; ActiveTab().MoveLaneOfSignalBelow(derivedRef, sourceRef); } // 5. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。 SyncTabsToDataSet(); return true; } bool Application::ApplyPendingDerivedBuild() { // 两类「构建为新信号」请求(均值滤波 / 弱波增强)机制完全一致, // 统一整理成同一份构建清单后按下标降序处理。 std::vector requests; { int laneIndex = -1; std::vector items; if (m_plot.TakePendingMeanFilterBuild(laneIndex, items)) { for (const PlotView::MeanFilterBuild& item : items) { DerivedSignalRequest request; request.fileIndex = item.fileIndex; request.signalIndex = item.signalIndex; request.points = item.points; request.iterations = item.iterations; request.weakBoost = false; requests.push_back(request); } } } { int laneIndex = -1; std::vector items; if (m_plot.TakePendingWeakBoostBuild(laneIndex, items)) { for (const PlotView::WeakBoostBuild& item : items) { DerivedSignalRequest request; request.fileIndex = item.fileIndex; request.signalIndex = item.signalIndex; request.levelPercent = item.levelPercent; request.iterations = item.iterations; request.weakBoost = true; requests.push_back(request); } } } if (requests.empty()) { return false; } if (m_tabs.empty()) { return false; } // **同一文件内必须按下标降序处理**:每次插入只让插入点之后的信号下标 +1, // 降序处理可保证尚未处理的信号下标始终有效(升序会把新插入的信号当成原信号)。 std::sort(requests.begin(), requests.end(), [](const DerivedSignalRequest& a, const DerivedSignalRequest& b) { if (a.fileIndex != b.fileIndex) { return a.fileIndex < b.fileIndex; } return a.signalIndex > b.signalIndex; }); int built = 0; DerivedSignalRequest last; for (const DerivedSignalRequest& request : requests) { if (BuildDerivedSignal(request, true)) { ++built; last = request; } } if (built > 0) { std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 "; if (last.weakBoost) { text += L"弱波增强 " + std::to_wstring(last.levelPercent) + L"% " + std::to_wstring(last.iterations) + L"次迭代信号。"; } else { text += std::to_wstring(last.points) + L"点均值 " + std::to_wstring(last.iterations) + L"次迭代滤波信号。"; } SetStatusMessage(text, false); } return built > 0; } 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); } 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 weakBoost; weakBoost.label = L"弱波增强"; weakBoost.id = PLOT_CMD_LANE_WEAK_BOOST; weakBoost.checked = m_plot.LaneWeakBoost(laneIndex); experimental.submenu.push_back(weakBoost); } 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::DispatchMenuResult(const MenuResult& result) { if (!result.valid) { return; } switch (result.scope) { case MenuScope::SIGNAL_PANEL: { const bool needRelayout = m_panel.OnMenuCommand(result); 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_BOOST: // 「实验性功能 → 弱波增强」:切换弱波增强曲线与参数面板。 m_plot.ToggleLaneWeakBoost(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(); } 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; } } LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { // 除鼠标移动与鼠标移出(由下面的分支按“是否只影响绘图区”精确判断)之外, // 其余消息都可能改变标签栏 / 工具栏 / 面板的外观,统一标记装饰区域为脏。 // WM_PAINT / WM_TIMER 的重绘范围由各自的处理分支判断,这里不做标记。 if (message != WM_PAINT && message != WM_MOUSEMOVE && message != WM_MOUSELEAVE && message != WM_SETCURSOR && message != WM_NCHITTEST && message != WM_TIMER && message != WM_ERASEBKGND) { m_chromeDirty = true; } // 菜单打开时优先消费鼠标与键盘事件,形成模态交互。 if (m_menu.IsOpen()) { // 菜单接管输入后,取消底层组件可能仍在进行的拖拽。 if (m_activeTarget != Target::NONE || m_draggingSplitter) { ReleaseCapture(); m_activeTarget = Target::NONE; m_draggingSplitter = false; m_panel.OnLButtonUp(0, 0); m_plot.OnLButtonUp(0, 0); } switch (message) { case WM_MOUSEMOVE: // 菜单内联输入框拖拽框选优先(不改变悬停项,避免选区被中途清掉)。 if (m_menu.IsEditing() && m_activeTarget == Target::EDIT_FIELD) { if (m_menu.OnEditMouseMove(GET_X_LPARAM(lParam))) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } if (m_menu.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam))) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; case WM_LBUTTONDOWN: { const int downX = GET_X_LPARAM(lParam); const int downY = GET_Y_LPARAM(lParam); // 点击菜单内联输入框:定位插入符并开始拖拽框选(与常规输入框一致)。 if (m_menu.IsOnEditField(downX, downY)) { m_menu.OnEditMouseDown(downX, downY); SetCapture(hwnd); m_activeTarget = Target::EDIT_FIELD; InvalidateRect(hwnd, nullptr, FALSE); return 0; } MenuResult result; m_menu.OnLButtonDown(downX, downY, result); InvalidateRect(hwnd, nullptr, FALSE); // 点击其它位置会先提交正在编辑的数值,需要立即落到数据层并刷新菜单显示。 if (result.valid) { DispatchMenuResult(result); } return 0; } case WM_LBUTTONUP: { if (m_menu.IsEditing() && m_activeTarget == Target::EDIT_FIELD) { ReleaseCapture(); m_activeTarget = Target::NONE; m_menu.OnEditMouseUp(); InvalidateRect(hwnd, nullptr, FALSE); return 0; } MenuResult result; m_menu.OnLButtonUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result); InvalidateRect(hwnd, nullptr, FALSE); DispatchMenuResult(result); return 0; } case WM_CHAR: { // 菜单内联输入框的字符输入(数值/名称),回车在此提交。 MenuResult result; if (m_menu.OnChar(static_cast(wParam), result)) { InvalidateRect(hwnd, nullptr, FALSE); } if (result.valid) { DispatchMenuResult(result); } return 0; } case WM_RBUTTONDOWN: case WM_MBUTTONDOWN: case WM_MOUSEWHEEL: m_menu.Close(); InvalidateRect(hwnd, nullptr, FALSE); return 0; case WM_KEYDOWN: { MenuResult result; m_menu.OnKeyDown(wParam, result); InvalidateRect(hwnd, nullptr, FALSE); if (result.valid) { DispatchMenuResult(result); } return 0; } case WM_KILLFOCUS: case WM_CANCELMODE: m_menu.Close(); InvalidateRect(hwnd, nullptr, FALSE); return 0; case WM_SETCURSOR: SetCursor(LoadCursorW(nullptr, IDC_ARROW)); return TRUE; default: break; } } // 拾色器打开时接管鼠标与键盘(优先级低于弹出菜单、高于常规组件)。 if (m_colorPicker.IsOpen()) { bool repaint = false; bool consume = false; switch (message) { case WM_MOUSEMOVE: repaint = m_colorPicker.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); consume = true; break; case WM_LBUTTONDOWN: { const int pickX = GET_X_LPARAM(lParam); const int pickY = GET_Y_LPARAM(lParam); // 拾色器接管输入后,取消底层组件可能仍在进行的拖拽。 m_panel.OnLButtonUp(0, 0); m_plot.OnLButtonUp(0, 0); m_activeTarget = Target::NONE; m_draggingSplitter = false; if (m_colorPicker.ContainsPoint(pickX, pickY)) { // 面板内部:开始拖动滑块,并吃掉本次点击,避免穿透到下方图层。 m_colorPicker.OnLButtonDown(pickX, pickY); SetCapture(hwnd); consume = true; } else { // 面板外部:收起面板;若落点在信号面板内则放行本次点击, // 便于用户在展开状态下直接改选预设色、样式或拖动滑块。 m_colorPicker.Close(); if (!m_panel.HitTest(pickX, pickY)) { InvalidateRect(hwnd, nullptr, FALSE); return 0; } } repaint = true; break; } case WM_SETCURSOR: { if (LOWORD(lParam) == HTCLIENT) { POINT point = {}; GetCursorPos(&point); ScreenToClient(hwnd, &point); HCURSOR cursor = m_colorPicker.QueryCursor(point.x, point.y); SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW)); return TRUE; } break; } case WM_LBUTTONUP: m_colorPicker.OnLButtonUp(); ReleaseCapture(); repaint = true; consume = true; break; case WM_KEYDOWN: m_colorPicker.OnKeyDown(wParam); repaint = true; consume = true; break; case WM_CHAR: // 字符输入转发给拾色器,用于 RGB 分量与十六进制色号的内联编辑。 repaint = m_colorPicker.OnChar(static_cast(wParam)); consume = true; break; case WM_RBUTTONDOWN: case WM_MBUTTONDOWN: case WM_MOUSEWHEEL: m_colorPicker.Close(); repaint = true; consume = true; break; default: break; } // 取色变化实时应用到当前选中信号。 std::uint32_t pickedColor = 0; if (m_colorPicker.TakeChanged(pickedColor)) { m_panel.ApplyColor(pickedColor); repaint = true; } if (repaint) { InvalidateRect(hwnd, nullptr, FALSE); } if (consume) { return 0; } } switch (message) { case WM_SIZE: { ReleaseBackBuffer(); Relayout(); InvalidateRect(hwnd, nullptr, FALSE); return 0; } case WM_GETMINMAXINFO: { MINMAXINFO* info = reinterpret_cast(lParam); info->ptMinTrackSize.x = static_cast(ScaleOf(static_cast(kMinClientWidth), m_scale)); info->ptMinTrackSize.y = static_cast(ScaleOf(static_cast(kMinClientHeight), m_scale)); return 0; } case WM_DPICHANGED: { const UINT dpi = HIWORD(wParam); const RECT* suggested = reinterpret_cast(lParam); if (suggested != nullptr) { SetWindowPos(hwnd, nullptr, suggested->left, suggested->top, suggested->right - suggested->left, suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE); } ApplyDpi(dpi); InvalidateRect(hwnd, nullptr, FALSE); return 0; } case WM_ERASEBKGND: return 1; case WM_PAINT: { // BeginPaint 会清空更新区域,因此必须先取出本次待重绘的范围: // 只有完全落在绘图区内的更新区域才允许走“局部回送”的快路径, // 其余情况(窗口首次显示、其它窗口遮挡后露出、DPI 变化等)必须整帧重绘。 RECT update = {}; const bool hasUpdate = GetUpdateRect(hwnd, &update, FALSE) != FALSE; if (hasUpdate) { const bool insidePlot = update.left >= static_cast(std::floor(m_plotRect.X)) && update.top >= static_cast(std::floor(m_plotRect.Y)) && update.right <= static_cast(std::ceil(m_plotRect.X + m_plotRect.Width)) && update.bottom <= static_cast(std::ceil(m_plotRect.Y + m_plotRect.Height)) && update.right > update.left && update.bottom > update.top; if (!insidePlot) { m_chromeDirty = true; } } PAINTSTRUCT paintStruct = {}; BeginPaint(hwnd, &paintStruct); Render(); EndPaint(hwnd, &paintStruct); return 0; } case WM_MOUSEMOVE: { if (!m_trackingLeave) { TRACKMOUSEEVENT track = {}; track.cbSize = sizeof(track); track.dwFlags = TME_LEAVE; track.hwndTrack = hwnd; TrackMouseEvent(&track); m_trackingLeave = true; } const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); const bool buttonDown = (wParam & (MK_LBUTTON | MK_RBUTTON | MK_MBUTTON)) != 0; // 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘: // 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。 const bool inPlot = UiPointInRect(m_plotRect, static_cast(x), static_cast(y)); const bool plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot); if (!plotOnlyMove) { m_chromeDirty = true; } m_mouseOverPlot = inPlot; if (HandleMouseMove(x, y, buttonDown)) { if (plotOnlyMove && !m_chromeDirty) { // 只失效绘图区,配合 WM_PAINT 中的判断即可走局部回送快路径。 InvalidatePlotRect(hwnd, m_plotRect); } else { InvalidateRect(hwnd, nullptr, FALSE); } } // 拖动(鼠标捕获)期间 Windows 不会派发 WM_SETCURSOR, // 因此需要主动刷新一次,否则光标形状会一直停在按下的状态。 if (buttonDown) { UpdateDragCursor(x, y); } return 0; } case WM_MOUSELEAVE: { m_trackingLeave = false; // 只有标签栏 / 工具栏 / 面板的悬停态变化才需要重绘装饰区域; // 绘图区自身的悬停态变化(提示浮层、信号光晕)只需重绘绘图区, // 这样拖拽光标时鼠标短暂移出窗口也不会退化成整帧重绘。 bool chromeChanged = m_tabBar.OnMouseLeave(); chromeChanged = m_toolbar.OnMouseLeave() || chromeChanged; chromeChanged = m_panel.OnMouseLeave() || chromeChanged; const bool plotChanged = m_plot.OnMouseLeave(); m_mouseOverPlot = false; if (chromeChanged) { m_chromeDirty = true; } if (plotChanged && !chromeChanged && !m_chromeDirty) { InvalidatePlotRect(hwnd, m_plotRect); } else if (chromeChanged || plotChanged) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } case WM_LBUTTONDOWN: { SetFocus(hwnd); const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); // 编辑态下点击输入框内部:定位插入符并开始拖拽框选,不再触发底层控件的选中/拖拽。 if (m_tabBar.OnEditMouseDown(x, y) || m_panel.OnEditMouseDown(x, y) || m_plot.OnEditMouseDown(x, y) || m_colorPicker.OnEditMouseDown(x, y)) { SetCapture(hwnd); m_activeTarget = Target::EDIT_FIELD; InvalidateRect(hwnd, nullptr, FALSE); return 0; } // 标签栏重命名处于编辑态时,点击输入框之外的**任何**位置都提交并退出编辑态。 // (面板与绘图区的输入框会在各自的 OnLButtonDown 内部提交,但点击它们之外 // 的区域时宿主根本不会把消息转给标签栏,重命名输入框会一直停留在编辑态。) if (m_tabBar.IsEditing()) { TabBarResult renameResult; m_tabBar.OnEditKey(VK_RETURN, renameResult); OnTabBarResult(renameResult); InvalidateRect(hwnd, nullptr, FALSE); } // 内联数值输入处于活动态时,点击绘图区之外视为提交(与线宽输入一致)。 if (m_plot.IsEditing() && !m_plot.HitTest(x, y)) { m_plot.OnEditKey(VK_RETURN); ApplyPendingRename(); } // 点击面板之外的位置时收起面板内展开的自定义颜色面板。 if (!m_panel.HitTest(x, y)) { if (m_panel.CloseTransientUi()) { InvalidateRect(hwnd, nullptr, FALSE); } } if (UiPointInRect(m_splitterRect, static_cast(x), static_cast(y))) { SetCapture(hwnd); m_draggingSplitter = true; m_activeTarget = Target::SPLITTER; m_splitterGrabOffset = x - static_cast(m_splitterRect.X); return 0; } if (m_tabBar.HitTest(x, y)) { SetCapture(hwnd); m_activeTarget = Target::TAB_BAR; m_tabBar.OnLButtonDown(x, y); InvalidateRect(hwnd, nullptr, FALSE); return 0; } if (m_toolbar.HitTest(x, y)) { SetCapture(hwnd); m_activeTarget = Target::TOOLBAR; ToolbarAction action = ToolbarAction::NONE; m_toolbar.OnLButtonDown(x, y, action); InvalidateRect(hwnd, nullptr, FALSE); return 0; } if (m_panel.HitTest(x, y)) { SetCapture(hwnd); m_activeTarget = Target::PANEL; if (m_panel.OnLButtonDown(x, y)) { InvalidateRect(hwnd, nullptr, TRUE); } return 0; } if (m_plot.HitTest(x, y)) { SetCapture(hwnd); m_activeTarget = Target::PLOT; if (m_plot.OnLButtonDown(x, y)) { // 绘图区内的点击也可能提交图例改名,需要立刻落到数据层。 ApplyPendingRename(); // 「构建为新信号」按钮(均值滤波 / 弱波增强):立即构建新信号并同步各视图。 ApplyPendingDerivedBuild(); InvalidateRect(hwnd, nullptr, FALSE); } // 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次, // 使"按下但尚未拖动"时能立刻看到十字/方向箭头提示。 UpdateDragCursor(x, y); return 0; } m_activeTarget = Target::NONE; return 0; } case WM_LBUTTONUP: { const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); ReleaseCapture(); switch (m_activeTarget) { case Target::SPLITTER: m_draggingSplitter = false; break; case Target::EDIT_FIELD: { // 结束输入框内的拖拽框选(选区保留)。 bool changed = m_tabBar.OnEditMouseUp(); changed = m_panel.OnEditMouseUp(x, y) || changed; changed = m_plot.OnEditMouseUp() || changed; changed = m_colorPicker.OnEditMouseUp() || changed; m_activeTarget = Target::NONE; if (changed) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } case Target::TAB_BAR: { TabBarResult result; m_tabBar.OnLButtonUp(x, y, result); m_activeTarget = Target::NONE; OnTabBarResult(result); InvalidateRect(hwnd, nullptr, FALSE); return 0; } case Target::TOOLBAR: { ToolbarAction action = ToolbarAction::NONE; m_toolbar.OnLButtonUp(x, y, action); m_activeTarget = Target::NONE; if (action != ToolbarAction::NONE) { ExecuteToolbarAction(action); return 0; } break; } case Target::PANEL: m_panel.OnLButtonUp(x, y); break; case Target::PLOT: m_plot.OnLButtonUp(x, y); break; case Target::NONE: default: break; } m_activeTarget = Target::NONE; InvalidateRect(hwnd, nullptr, TRUE); return 0; } case WM_MBUTTONDOWN: { // 鼠标中键:在绘图区内按下即进入自由平移(任何缩放模式下都可用)。 const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); if (m_plot.IsEditing()) { // 内联输入中:中键视为提交(与左键点外一致)。 m_plot.OnEditKey(VK_RETURN); ApplyPendingRename(); } if (m_plot.HitTest(x, y)) { SetCapture(hwnd); m_middlePanning = true; m_activeTarget = Target::PLOT; if (m_plot.OnMButtonDown(x, y)) { ApplyPendingRename(); InvalidateRect(hwnd, nullptr, FALSE); } UpdateDragCursor(x, y); } return 0; } case WM_MBUTTONUP: { if (m_middlePanning) { m_middlePanning = false; m_plot.OnMButtonUp(); ReleaseCapture(); m_activeTarget = Target::NONE; UpdateDragCursor(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); InvalidateRect(hwnd, nullptr, TRUE); } return 0; } case WM_LBUTTONDBLCLK: { const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); bool changed = false; if (m_tabBar.HitTest(x, y)) { SetFocus(hwnd); changed = m_tabBar.OnDoubleClick(x, y); if (m_tabBar.IsEditing()) { // 进入重命名后该标签会加宽,需要重新排布标签栏。 Relayout(); } } else if (m_panel.HitTest(x, y)) { changed = m_panel.OnDoubleClick(x, y); } else if (m_plot.HitTest(x, y)) { changed = m_plot.OnDoubleClick(x, y); } if (changed) { InvalidateRect(hwnd, nullptr, TRUE); } return 0; } case WM_RBUTTONDOWN: { const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); // 「选区放大」正在框选时右键 = 放弃当前选区(显示提示), // 与 Esc 不同:这里**不退出**选区放大模式,也不弹出网格菜单。 // 左键仍按着,因此保留鼠标捕获与激活目标,松手时不会命中被放弃的选区。 if (m_plot.IsRubberSelecting()) { m_plot.CancelDrag(true); InvalidateRect(hwnd, nullptr, FALSE); return 0; } // 编辑态下右键 = 取消选区并把插入符移到文本末尾(常规输入框行为), // 其它操作(弹菜单、拖拽)一并被吞掉。 if (m_tabBar.IsEditing() || m_panel.IsEditing() || m_plot.IsEditing() || m_colorPicker.IsEditing()) { bool changed = m_tabBar.CollapseEditSelectionToEnd(); changed = m_panel.CollapseEditSelectionToEnd() || changed; changed = m_plot.CollapseEditSelectionToEnd() || changed; changed = m_colorPicker.CollapseEditSelectionToEnd() || changed; if (changed) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } POINT cursor = {x, y}; ClientToScreen(hwnd, &cursor); if (m_tabBar.HitTest(x, y)) { TabBarResult result; if (m_tabBar.OnRButtonDown(x, y, cursor.x, cursor.y, result)) { OnTabBarResult(result); // 自绘菜单会覆盖在客户区之上,必须请求重绘才能显示。 InvalidateRect(hwnd, nullptr, FALSE); } return 0; } if (m_panel.HitTest(x, y)) { SetCapture(hwnd); m_activeTarget = Target::PANEL; if (m_panel.OnRButtonDown(x, y)) { InvalidateRect(hwnd, nullptr, TRUE); } ReleaseCapture(); m_activeTarget = Target::NONE; return 0; } // 绘图区右键:弹出该网格的 Y 轴设置菜单(含上下限输入)。 if (m_plot.HitTest(x, y)) { const int laneIndex = m_plot.LaneIndexAt(x, y); if (laneIndex >= 0) { // 菜单打开后鼠标移动被菜单独占,绘图区不再收到移动事件; // 若不先清掉悬停状态,右键前停留在曲线上的「可拖拽光晕」与提示气泡 // 会一直挂在原地(用户报告的"进入网格菜单后高亮/文本提示不消失")。 m_plot.OnMouseLeave(); ShowPlotContextMenu(laneIndex, x, y); InvalidateRect(hwnd, nullptr, FALSE); } return 0; } return 0; } case WM_RBUTTONUP: { // 左键仍按着时不释放捕获与激活目标(例如框选过程中右键放弃选区后再松开左键)。 if ((wParam & MK_LBUTTON) == 0) { ReleaseCapture(); m_activeTarget = Target::NONE; } return 0; } case WM_MOUSEWHEEL: { POINT point = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; ScreenToClient(hwnd, &point); const int delta = GET_WHEEL_DELTA_WPARAM(wParam); const bool shift = (GET_KEYSTATE_WPARAM(wParam) & MK_SHIFT) != 0; bool changed = false; if (m_panel.HitTest(point.x, point.y)) { changed = m_panel.OnMouseWheel(delta); } else if (m_plot.HitTest(point.x, point.y)) { changed = m_plot.OnMouseWheel(delta, shift); } if (changed) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } case WM_SETCURSOR: { if (LOWORD(lParam) == HTCLIENT) { POINT point = {}; GetCursorPos(&point); ScreenToClient(hwnd, &point); HCURSOR cursor = nullptr; if (UiPointInRect(m_splitterRect, static_cast(point.x), static_cast(point.y))) { cursor = LoadCursorW(nullptr, IDC_SIZEWE); } else if (m_plot.HitTest(point.x, point.y)) { cursor = m_plot.QueryCursor(point.x, point.y); } else if (UiPointInRect(m_panelRect, static_cast(point.x), static_cast(point.y))) { cursor = m_panel.QueryCursor(point.x, point.y); } if (cursor == nullptr) { cursor = LoadCursorW(nullptr, IDC_ARROW); } SetCursor(cursor); return TRUE; } break; } case WM_KEYDOWN: // 标签栏重命名与数值输入优先接管按键(回车提交、Esc 取消、退格删除)。 if (m_tabBar.IsEditing()) { TabBarResult tabResult; m_tabBar.OnEditKey(wParam, tabResult); OnTabBarResult(tabResult); if (!m_tabBar.IsEditing()) { // 退出重命名后标签宽度恢复,需要重新排布。 Relayout(); } InvalidateRect(hwnd, nullptr, FALSE); return 0; } if (m_panel.IsEditing()) { if (m_panel.OnEditKey(wParam)) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } if (m_plot.IsEditing()) { m_plot.OnEditKey(wParam); ApplyPendingRename(); InvalidateRect(hwnd, nullptr, FALSE); return 0; } if (HandleKeyDown(wParam)) { InvalidateRect(hwnd, nullptr, TRUE); return 0; } break; case WM_CHAR: { // 输入状态由对应控件自行校验可接受的字符(数值仅收数字与小数点,名称可收任意可打印字符)。 if (m_tabBar.IsEditing()) { if (m_tabBar.OnEditChar(static_cast(wParam))) { // 标签宽度随输入内容增长(重命名时上限更宽),需重新排布才能看到完整名称。 if (m_tabBar.IsEditing()) { Relayout(); } InvalidateRect(hwnd, nullptr, FALSE); } return 0; } if (m_panel.IsEditing()) { if (m_panel.OnEditChar(static_cast(wParam))) { InvalidateRect(hwnd, nullptr, FALSE); } return 0; } if (m_plot.IsEditing()) { if (m_plot.OnEditChar(static_cast(wParam))) { ApplyPendingRename(); InvalidateRect(hwnd, nullptr, FALSE); } return 0; } break; } case WM_KILLFOCUS: // 失焦时提交输入并收起临时界面,避免失去鼠标前景后控件仍处于编辑态。 m_panel.CloseTransientUi(); m_colorPicker.Close(); m_menu.Close(); InvalidateRect(hwnd, nullptr, FALSE); return 0; case kMessageRemoveFile: { RemoveFile(static_cast(wParam)); return 0; } case kMessageRemoveSignal: { // wParam = 文件下标,lParam = 文件内信号下标。 RemoveSignal(static_cast(wParam), static_cast(static_cast(lParam))); return 0; } case WM_TIMER: { // 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。 if (wParam == kHintTimerId) { InvalidateRect(hwnd, nullptr, FALSE); return 0; } break; } case WM_DROPFILES: { HDROP drop = reinterpret_cast(wParam); const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); int loaded = 0; for (UINT index = 0; index < count; ++index) { const UINT length = DragQueryFileW(drop, index, nullptr, 0); if (length == 0) { continue; } std::vector buffer(static_cast(length) + 1, L'\0'); if (DragQueryFileW(drop, index, buffer.data(), length + 1) > 0) { if (LoadFile(std::wstring(buffer.data()))) { ++loaded; } } } DragFinish(drop); if (loaded > 0) { SetStatusMessage(L"已加载 " + std::to_wstring(loaded) + L" 个文件,请在左侧勾选需要查看的信号。", false); } return 0; } case WM_DESTROY: PostQuitMessage(0); return 0; default: break; } return DefWindowProcW(hwnd, message, wParam, lParam); } } // namespace jdi