// Application 实现分片 —— 本文件只包含 Application 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 // // 共用的常量与自由函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 // 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。 #include "ApplicationInternal.hpp" namespace jdi { 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; // 隐藏窗口启动(自动化/后台)时没人能点掉模态错误框,会自动降级为非模态提示。 SetModalErrorsEnabled(showCommand != SW_HIDE); 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)); // ⚠️ 同一个进程里再次创建 `Application`(离屏测试、单进程多次启动)时窗口类已经注册, // 这时**不是错误**:直接复用已注册的类即可。旧实现把它当致命错误弹模态框, // 会让调用方永远阻塞在对话框上。 if (RegisterClassExW(&windowClass) == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { 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(false); Relayout(); // 标题栏内存占用按固定间隔刷新(不使用每帧刷新,避免频繁改动窗口标题)。 SetTimer(m_hwnd, kRamTimerId, kRamTimerPeriodMs, nullptr); 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; } // 注销窗口类:否则同一进程里再次创建 `Application` 时会命中 // `ERROR_CLASS_ALREADY_EXISTS`(离屏测试需要多次构造/销毁)。 if (m_hwnd != nullptr) { DestroyWindow(m_hwnd); m_hwnd = nullptr; } UnregisterClassW(kWindowClassName, m_instance); } 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)]; } 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::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); // 信号列表 / 样式区「目标」行的悬停 → 绘图区联动高亮(网格变色 + 该信号曲线高亮)。 // 用户 2026-10-15 要求;鼠标在网格里移动**不再**触发网格变色高亮。 // ⚠️ 每帧同步(而不是只在 WM_MOUSEMOVE 里同步)才能覆盖全部改变悬停的路径: // 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。 m_plot.SetExternalHoverSignal(m_panel.HoveredSignal()); 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; } // 信号列表的动画(目标信号滚动 / 分组折起展开):仅在动画进行中保持定时重绘。 if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive()) { if (!m_scrollTimerActive) { SetTimer(m_hwnd, kScrollTimerId, kScrollTimerPeriodMs, nullptr); m_scrollTimerActive = true; } } else if (m_scrollTimerActive) { KillTimer(m_hwnd, kScrollTimerId); m_scrollTimerActive = false; } } void Application::SetStatusMessage(const std::wstring& text, bool isError) { // 界面不再占用底部空间显示常驻提示;仅把真实错误反馈给用户。 if (!isError || text.empty()) { return; } // 模态错误框会抢断用户当前操作,因此把"是否需要打断"交给宿主决定: // 允许时弹框;不允许时改为绘图区底部的非模态浮层提示。 if (m_modalErrorsEnabled) { MessageBoxW(m_hwnd, text.c_str(), kWindowTitle, MB_ICONWARNING | MB_OK); return; } m_plot.ShowPersistentHint(text); } 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 && message != kMessagePlotDerivedReady) { 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 kMessagePlotDerivedReady: // 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。 InvalidatePlotRect(hwnd, m_plotRect); return 0; case kMessagePlotLowMemory: // 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算, // 这里给出一次明确的告警。界面保持可用,绝不会因为内存不足而崩溃。 SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n" L"请关闭部分分组或信号后重试。", true); InvalidatePlotRect(hwnd, m_plotRect); return 0; 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; // 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘: // 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。 // // ⚠️ **必须同时看“当前正在拖拽哪个组件”**:拖拽信号面板的滚动条时,鼠标很容易 // 移动到绘图区上方,若只看 `inPlot && m_mouseOverPlot` 就会判定为“只影响绘图区”、 // 只失效绘图区 —— 面板的滚动位置明明已经变了,却一直不重绘( // 用户报告的“拖住滚动条移到网格区域后滑块卡在原地不动”)。 const bool draggingNonPlot = (m_activeTarget != Target::NONE) && (m_activeTarget != Target::PLOT) && (m_activeTarget != Target::EDIT_FIELD); const bool inPlot = UiPointInRect(m_plotRect, static_cast(x), static_cast(y)); const bool plotOnlyMove = !draggingNonPlot && ((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); } // 联动:在绘图区选中了某条信号 ⇒ 让信号列表平滑滚动到它并完整显示。 ApplyPendingPanelScroll(); // 拖动期间 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); // ⚠️ **工具栏必须在这里也当作“按下”处理**:窗口类带 `CS_DBLCLKS`,快速连点同一个 // 按钮时,第二次(及之后的快速按下)会被 Windows 合成为 `WM_LBUTTONDBLCLK`。 // 工具栏没有双击语义,若这里不处理,那一次按下就完全丢失 —— // 用户看到的正是“连点好几下、开关状态只切换了几次”。 if (m_toolbar.HitTest(x, y)) { SetFocus(hwnd); SetCapture(hwnd); m_activeTarget = Target::TOOLBAR; ToolbarAction action = ToolbarAction::NONE; m_toolbar.OnLButtonDown(x, y, action); InvalidateRect(hwnd, nullptr, FALSE); return 0; } 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); } // 联动:双击图例改名也会选中该信号 ⇒ 同步把列表滚动到它。 ApplyPendingPanelScroll(); 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 kMessageRemoveGroup: { RemoveGroup(static_cast(wParam)); return 0; } case kMessageRemoveSignal: { // wParam = 分组下标,lParam = 组内信号下标。 RemoveSignal(static_cast(wParam), static_cast(static_cast(lParam))); return 0; } case WM_TIMER: { // 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。 if (wParam == kRamTimerId) { UpdateWindowTitle(); return 0; } // 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。 if (wParam == kHintTimerId) { InvalidateRect(hwnd, nullptr, FALSE); return 0; } // 信号列表动画(「滚动到目标信号」与「分组折起 / 展开」):推进一步并重绘 //(Render 会在动画结束后停表)。 if (wParam == kScrollTimerId) { m_panel.AdvanceScrollAnimation(); m_panel.AdvanceGroupAnimations(); 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